diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index ae2b665..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: '3.x' - - run: pip install flake8 - - run: flake8 . --max-line-length=120 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4e3bcf2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Install package + run: pip install -e . + + - name: Run tests + run: pytest tests/ -v + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install flake8 + - run: flake8 . --max-line-length=120 diff --git a/.gitignore b/.gitignore index 7325ab7..4942ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,292 +1,56 @@ -# Created by https://www.toptal.com/developers/gitignore/api/pycharm,python -# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm,python - -# my stuff -.*.md - -### PyCharm ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format +# IDE +.idea/ +*.iml *.iws +*.ipr +.vscode/ -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### PyCharm Patch ### -# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 - -# *.iml -# modules.xml -# .idea/misc.xml -# *.ipr - -# Sonarlint plugin -# https://plugins.jetbrains.com/plugin/7973-sonarlint -.idea/**/sonarlint/ - -# SonarQube Plugin -# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin -.idea/**/sonarIssues.xml - -# Markdown Navigator plugin -# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced -.idea/**/markdown-navigator.xml -.idea/**/markdown-navigator-enh.xml -.idea/**/markdown-navigator/ - -# Cache file creation bug -# See https://youtrack.jetbrains.com/issue/JBR-2257 -.idea/$CACHE_FILE$ - -# CodeStream plugin -# https://plugins.jetbrains.com/plugin/12206-codestream -.idea/codestream.xml - -# Azure Toolkit for IntelliJ plugin -# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij -.idea/**/azureSettings.xml - -### Python ### -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] *$py.class - -# C extensions *.so - -# Distribution / packaging .Python build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg *.egg +.eggs/ +.installed.cfg MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ -# Unit test / coverage reports -htmlcov/ +# Testing / Coverage .tox/ .nox/ .coverage .coverage.* .cache +.pytest_cache/ +htmlcov/ nosetests.xml coverage.xml *.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy +# Type checkers .mypy_cache/ .dmypy.json dmypy.json - -# Pyre type checker .pyre/ - -# pytype static type analyzer .pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -### Python Patch ### -# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration -poetry.toml - -# ruff .ruff_cache/ -# LSP config files -pyrightconfig.json +# Misc +*.log +*.pot +*.mo +.DS_Store -# End of https://www.toptal.com/developers/gitignore/api/pycharm,python +# Personal +.*.md diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 878d08e..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 4616d5e..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/nextdnsctl.iml b/.idea/nextdnsctl.iml deleted file mode 100644 index 762f8b7..0000000 --- a/.idea/nextdnsctl.iml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 7523b95..25e3a88 100644 --- a/README.md +++ b/README.md @@ -7,79 +7,176 @@ A community-driven CLI tool for managing NextDNS profiles declaratively. **Disclaimer**: This is an unofficial tool, not affiliated with NextDNS. Built by a user, for users. -> ⚠️ **Note**: While `nextdnsctl` now handles API rate limiting and retries, it is **not recommended for importing very large blocklists**. For large-scale filtering, prefer using NextDNS’s built-in curated blocklists under the **Privacy** tab, and use the `denylist` feature for specific overrides or fine-tuning. +> **Note**: While `nextdnsctl` handles API rate limiting and retries, it is **not recommended for importing very large +blocklists**. For large-scale filtering, prefer using NextDNS's built-in curated blocklists under the **Privacy** tab, +> and use the `denylist` feature for specific overrides or fine-tuning. ## Features -- Bulk add/remove domains to the NextDNS denylist and allowlist. -- Import domains from a file or URL to the denylist and allowlist. -- List all profiles to find their IDs. -- More to come: full config sync, etc. + +- Bulk add/remove domains to the NextDNS denylist and allowlist +- Import domains from a file or URL +- Export current list to a file for backup +- List and clear all entries in a list +- Parallel API requests for faster bulk operations +- Dry-run mode to preview changes before applying +- Use profile names or IDs interchangeably ## Installation -1. Install Python 3.6+. -2. Clone or install: - ```bash - pip install nextdnsctl - ``` -## Usage -1. Set up your API key (find it at https://my.nextdns.io/account): - ```bash - nextdnsctl auth - ``` -2. List profiles: +```bash +pip install nextdnsctl +``` + +Requires Python 3.10+. + +## Quick Start + +```bash +# Authenticate (find your API key at https://my.nextdns.io/account) +nextdnsctl auth + +# List your profiles +nextdnsctl profile-list + +# Add domains to denylist (using profile name or ID) +nextdnsctl denylist add "My Profile" bad.com evil.com + +# Preview changes without applying them +nextdnsctl --dry-run denylist import myprofile blocklist.txt +``` + +## Authentication + +The API key can be provided in two ways (in order of priority): + +1. **Environment variable** (recommended for CI/CD): ```bash + export NEXTDNS_API_KEY=your-api-key nextdnsctl profile-list ``` -### Denylist Management -3. Add domains to denylist: - ```bash - nextdnsctl denylist add bad.com evil.com - ``` -4. Remove domains from denylist: - ```bash - nextdnsctl denylist remove bad.com - ``` -5. Import domains from a file or URL: - - From a file: - ```bash - nextdnsctl denylist import /path/to/blocklist.txt - ``` - - From a URL: - ```bash - nextdnsctl denylist import https://example.com/blocklist.txt - ``` - - Use `--inactive` to add domains as inactive (not blocked): - ```bash - nextdnsctl denylist import blocklist.txt --inactive - ``` - -### Allowlist Management -6. Add domains to allowlist: - ```bash - nextdnsctl allowlist add good.com trusted.com - ``` -7. Remove domains from allowlist: +2. **Config file** (created by `auth` command): ```bash - nextdnsctl allowlist remove good.com + nextdnsctl auth + # Stored in ~/.nextdnsctl/config.json with secure permissions ``` -8. Import domains from a file or URL: - - From a file: - ```bash - nextdnsctl allowlist import /path/to/allowlist.txt - ``` - - From a URL: - ```bash - nextdnsctl allowlist import https://example.com/allowlist.txt - ``` - - Use `--inactive` to add domains as inactive (not allowed): - ```bash - nextdnsctl allowlist import allowlist.txt --inactive - ``` + +## Global Options + +| Option | Description | +|----------------------|-------------------------------------------------------| +| `--concurrency N` | Number of parallel API requests (1-20, default: 5) | +| `--dry-run` | Show what would be done without making changes | +| `--retry-attempts N` | Number of retry attempts for API calls (default: 4) | +| `--retry-delay N` | Initial delay between retries in seconds (default: 1) | +| `--timeout N` | Request timeout in seconds (default: 10) | + +## Profile Identification + +All commands accept either a **profile ID** or **profile name** (case-insensitive): + +```bash +# Using profile ID +nextdnsctl denylist list abc123 + +# Using profile name +nextdnsctl denylist list "My Profile" +``` + +## Denylist Commands + +### List entries + +```bash +nextdnsctl denylist list +nextdnsctl denylist list --active-only +nextdnsctl denylist list --inactive-only +``` + +### Add domains + +```bash +nextdnsctl denylist add domain1.com domain2.com +nextdnsctl denylist add domain.com --inactive +``` + +### Remove domains + +```bash +nextdnsctl denylist remove domain1.com domain2.com +``` + +### Import from file or URL + +```bash +nextdnsctl denylist import /path/to/blocklist.txt +nextdnsctl denylist import https://example.com/blocklist.txt +nextdnsctl denylist import blocklist.txt --inactive +``` + +The import file format supports: +- One domain per line +- Comments starting with `#` +- Inline comments (e.g., `example.com # reason`) +- Empty lines (ignored) + +### Export to file + +```bash +nextdnsctl denylist export backup.txt +nextdnsctl denylist export # outputs to stdout +nextdnsctl denylist export --active-only > active.txt +``` + +### Clear all entries + +```bash +nextdnsctl denylist clear # asks for confirmation +nextdnsctl denylist clear --yes # skip confirmation +``` + +## Allowlist Commands + +All denylist commands are available for allowlist with the same syntax: + +```bash +nextdnsctl allowlist list +nextdnsctl allowlist add good.com trusted.com +nextdnsctl allowlist remove domain.com +nextdnsctl allowlist import allowlist.txt +nextdnsctl allowlist export backup.txt +nextdnsctl allowlist clear --yes +``` + +## Parallel Requests + +By default, bulk operations run 5 concurrent API requests. Adjust with `--concurrency`: + +```bash +# Faster (more concurrent requests) +nextdnsctl --concurrency 10 denylist import myprofile blocklist.txt + +# Sequential mode (verbose per-domain output, like v0.2.0) +nextdnsctl --concurrency 1 denylist import myprofile blocklist.txt +``` + +## Dry-Run Mode + +Preview changes before applying them: + +```bash +$ nextdnsctl --dry-run denylist add myprofile bad.com evil.com +[DRY-RUN] Would add 2 domain(s): + - bad.com + - evil.com + +[DRY-RUN] No changes made. +``` ## Contributing + Pull requests welcome! See [docs/contributing.md](docs/contributing.md) for details. ## License + MIT License - see [LICENSE](LICENSE). diff --git a/nextdnsctl/__init__.py b/nextdnsctl/__init__.py index e69de29..2a6960a 100644 --- a/nextdnsctl/__init__.py +++ b/nextdnsctl/__init__.py @@ -0,0 +1,3 @@ +"""nextdnsctl - A CLI tool for managing NextDNS profiles.""" + +__version__ = "1.0.0" diff --git a/nextdnsctl/api.py b/nextdnsctl/api.py index 84f8409..da1db1b 100644 --- a/nextdnsctl/api.py +++ b/nextdnsctl/api.py @@ -1,34 +1,40 @@ -import requests import time +from typing import Any, Dict, List, Optional +from urllib.parse import urljoin + +import requests from requests.exceptions import RequestException +from . import __version__ from .config import load_api_key -API_BASE = "https://api.nextdns.io" +API_BASE = "https://api.nextdns.io/" DEFAULT_RETRIES = 4 DEFAULT_DELAY = 1 # For general errors or Retry-After scenarios DEFAULT_TIMEOUT = 10 -USER_AGENT = "nextdnsctl/0.2.0" -DEFAULT_PATIENT_RETRY_PAUSE_SECONDS = 60 # Added: Pause for unspecific 429s +USER_AGENT = f"nextdnsctl/{__version__}" +DEFAULT_PATIENT_RETRY_PAUSE_SECONDS = 60 # Pause for unspecific 429s + +class RateLimitStillActiveError(Exception): + """Raised when API rate limit persists after all retry attempts.""" -# Custom Exception for persistent rate limits -class RateLimitStillActiveError(Exception): # Added pass def api_call( - method, - endpoint, - data=None, - retries=DEFAULT_RETRIES, - delay=DEFAULT_DELAY, - timeout=DEFAULT_TIMEOUT, -): + method: str, + endpoint: str, + data: Optional[Dict[str, Any]] = None, + retries: int = DEFAULT_RETRIES, + delay: float = DEFAULT_DELAY, + timeout: float = DEFAULT_TIMEOUT, +) -> Optional[Dict[str, Any]]: """Make an API request to NextDNS.""" api_key = load_api_key() headers = {"X-Api-Key": api_key, "User-Agent": USER_AGENT} - url = f"{API_BASE}{endpoint}" + # Use urljoin for safer URL construction + url = urljoin(API_BASE, endpoint.lstrip("/")) for attempt in range(retries + 1): try: @@ -72,7 +78,7 @@ def api_call( if response.status_code not in (200, 201, 204): # For server errors (5xx), retry with exponential backoff if retries are available if response.status_code >= 500 and attempt < retries: - current_delay = delay * (2 ** attempt) + current_delay = delay * (2**attempt) print( f"Server error ({response.status_code}). Retrying in {current_delay}s " f"(attempt {attempt + 1}/{retries + 1})..." @@ -104,7 +110,7 @@ def api_call( except RequestException as e: if attempt < retries: - current_delay = delay * (2 ** attempt) + current_delay = delay * (2**attempt) print( f"Network error ({e}). Retrying in {current_delay}s " f"(attempt {attempt + 1}/{retries + 1})..." @@ -118,42 +124,76 @@ def api_call( ) -def get_profiles(**kwargs): +def get_profiles(**kwargs: Any) -> List[Dict[str, Any]]: """Retrieve all NextDNS profiles.""" - return api_call("GET", "/profiles", **kwargs)["data"] + response = api_call("GET", "profiles", **kwargs) + if response is None: + raise Exception("Unexpected empty response from profiles endpoint") + return response["data"] + + +# Generic domain list functions +def get_domain_list( + profile_id: str, list_type: str, **kwargs: Any +) -> List[Dict[str, Any]]: + """Retrieve the current list (denylist/allowlist) for a profile.""" + response = api_call("GET", f"profiles/{profile_id}/{list_type}", **kwargs) + if response is None: + raise Exception(f"Unexpected empty response from {list_type} endpoint") + return response["data"] + + +def add_to_domain_list( + profile_id: str, + list_type: str, + domain: str, + active: bool = True, + **kwargs: Any, +) -> str: + """Add a domain to a list (denylist/allowlist).""" + data = {"id": domain, "active": active} + api_call("POST", f"profiles/{profile_id}/{list_type}", data=data, **kwargs) + return f"Added {domain} as {'active' if active else 'inactive'}" -def get_denylist(profile_id, **kwargs): +def remove_from_domain_list( + profile_id: str, list_type: str, domain: str, **kwargs: Any +) -> str: + """Remove a domain from a list (denylist/allowlist).""" + api_call("DELETE", f"profiles/{profile_id}/{list_type}/{domain}", **kwargs) + return f"Removed {domain}" + + +# Convenience wrappers for backwards compatibility +def get_denylist(profile_id: str, **kwargs: Any) -> List[Dict[str, Any]]: """Retrieve the current denylist for a profile.""" - return api_call("GET", f"/profiles/{profile_id}/denylist", **kwargs)["data"] + return get_domain_list(profile_id, "denylist", **kwargs) -def add_to_denylist(profile_id, domain, active=True, **kwargs): +def add_to_denylist( + profile_id: str, domain: str, active: bool = True, **kwargs: Any +) -> str: """Add a domain to the denylist.""" - data = {"id": domain, "active": active} - api_call("POST", f"/profiles/{profile_id}/denylist", data=data, **kwargs) - return f"Added {domain} as {'active' if active else 'inactive'}" + return add_to_domain_list(profile_id, "denylist", domain, active, **kwargs) -def remove_from_denylist(profile_id, domain, **kwargs): +def remove_from_denylist(profile_id: str, domain: str, **kwargs: Any) -> str: """Remove a domain from the denylist.""" - api_call("DELETE", f"/profiles/{profile_id}/denylist/{domain}", **kwargs) - return f"Removed {domain}" + return remove_from_domain_list(profile_id, "denylist", domain, **kwargs) -def get_allowlist(profile_id, **kwargs): +def get_allowlist(profile_id: str, **kwargs: Any) -> List[Dict[str, Any]]: """Retrieve the current allowlist for a profile.""" - return api_call("GET", f"/profiles/{profile_id}/allowlist", **kwargs)["data"] + return get_domain_list(profile_id, "allowlist", **kwargs) -def add_to_allowlist(profile_id, domain, active=True, **kwargs): +def add_to_allowlist( + profile_id: str, domain: str, active: bool = True, **kwargs: Any +) -> str: """Add a domain to the allowlist.""" - data = {"id": domain, "active": active} - api_call("POST", f"/profiles/{profile_id}/allowlist", data=data, **kwargs) - return f"Added {domain} as {'active' if active else 'inactive'}" + return add_to_domain_list(profile_id, "allowlist", domain, active, **kwargs) -def remove_from_allowlist(profile_id, domain, **kwargs): +def remove_from_allowlist(profile_id: str, domain: str, **kwargs: Any) -> str: """Remove a domain from the allowlist.""" - api_call("DELETE", f"/profiles/{profile_id}/allowlist/{domain}", **kwargs) - return f"Removed {domain}" + return remove_from_domain_list(profile_id, "allowlist", domain, **kwargs) diff --git a/nextdnsctl/config.py b/nextdnsctl/config.py index fc787a9..d7b72a4 100644 --- a/nextdnsctl/config.py +++ b/nextdnsctl/config.py @@ -1,21 +1,40 @@ import json import os +import stat -CONFIG_DIR = os.path.expanduser("~/.nextdnsctl") -CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") +CONFIG_DIR: str = os.path.expanduser("~/.nextdnsctl") +CONFIG_FILE: str = os.path.join(CONFIG_DIR, "config.json") +ENV_VAR_NAME: str = "NEXTDNS_API_KEY" -def save_api_key(api_key): - """Save the NextDNS API key to a local config file.""" - os.makedirs(CONFIG_DIR, exist_ok=True) +def save_api_key(api_key: str) -> None: + """Save the NextDNS API key to a local config file with secure permissions.""" + os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True) with open(CONFIG_FILE, "w") as f: json.dump({"api_key": api_key}, f) + # Set file permissions to read/write for owner only (600) + os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) -def load_api_key(): - """Load the NextDNS API key from the config file.""" +def load_api_key() -> str: + """ + Load the NextDNS API key. + + Priority: + 1. NEXTDNS_API_KEY environment variable + 2. Config file (~/.nextdnsctl/config.json) + """ + # Check environment variable first + env_key = os.environ.get(ENV_VAR_NAME) + if env_key: + return env_key + + # Fall back to config file if not os.path.exists(CONFIG_FILE): - raise ValueError("No API key found. Run 'nextdnsctl auth ' first.") + raise ValueError( + f"No API key found. Set {ENV_VAR_NAME} environment variable " + "or run 'nextdnsctl auth '." + ) with open(CONFIG_FILE, "r") as f: config = json.load(f) if "api_key" not in config: diff --git a/nextdnsctl/nextdnsctl.py b/nextdnsctl/nextdnsctl.py index e483d68..95453a7 100644 --- a/nextdnsctl/nextdnsctl.py +++ b/nextdnsctl/nextdnsctl.py @@ -1,35 +1,130 @@ +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any, Callable, Iterator, List, Optional, Sequence, Tuple # noqa: F401 + import click import requests +from . import __version__ from .config import save_api_key, load_api_key from .api import ( get_profiles, - add_to_denylist, - remove_from_denylist, - add_to_allowlist, - remove_from_allowlist, + get_domain_list, + add_to_domain_list, + remove_from_domain_list, DEFAULT_RETRIES, DEFAULT_DELAY, DEFAULT_TIMEOUT, RateLimitStillActiveError, ) +DEFAULT_CONCURRENCY = 5 + + +def _resolve_profile_id(ctx: click.Context, profile_identifier: str) -> str: + """ + Resolve a profile identifier (ID or name) to a profile ID. + + If the identifier matches an existing profile ID, return it directly. + Otherwise, search for a profile with a matching name. + Caches the profiles list in ctx.obj to avoid repeated API calls. + """ + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } -__version__ = "0.2.0" + # Get or fetch profiles (cache in ctx.obj) + if "profiles_cache" not in ctx.obj: + try: + ctx.obj["profiles_cache"] = get_profiles(**api_params) + except Exception as e: + raise click.ClickException(f"Failed to fetch profiles: {e}") + + profiles = ctx.obj["profiles_cache"] + + # First, check if it's a direct ID match + for profile in profiles: + if profile.get("id") == profile_identifier: + return profile_identifier + + # Otherwise, search by name (case-insensitive) + for profile in profiles: + if profile.get("name", "").lower() == profile_identifier.lower(): + return profile["id"] + + # No match found + available = ", ".join(f"'{p.get('name')}' ({p.get('id')})" for p in profiles) + raise click.ClickException( + f"Profile '{profile_identifier}' not found. " f"Available profiles: {available}" + ) # Helper function to perform operations on a list of domains def _perform_domain_operations( - ctx, - domains_to_process, - operation_callable, - item_name_singular="domain", - action_verb="process", -): + ctx: click.Context, + domains_to_process: Sequence[str], + operation_callable: Callable[[str], str], + item_name_singular: str = "domain", + action_verb: str = "process", +) -> bool: """ Iterates over a list of items (e.g., domains) and performs an operation on each. Returns True if all non-critical operations were successful, False otherwise. Exits script if RateLimitStillActiveError is encountered. + + Supports parallel execution when concurrency > 1. + Supports dry-run mode to show what would be done without making changes. """ + dry_run = ctx.obj.get("dry_run", False) + concurrency = ctx.obj.get("concurrency", DEFAULT_CONCURRENCY) + + # Dry-run mode: just show what would be done + if dry_run: + return _perform_domain_operations_dry_run( + domains_to_process, item_name_singular, action_verb + ) + + # Sequential mode (concurrency == 1): preserve original verbose behavior + if concurrency == 1: + return _perform_domain_operations_sequential( + ctx, domains_to_process, operation_callable, item_name_singular, action_verb + ) + + # Parallel mode + return _perform_domain_operations_parallel( + ctx, + domains_to_process, + operation_callable, + item_name_singular, + action_verb, + concurrency, + ) + + +def _perform_domain_operations_dry_run( + domains_to_process: Sequence[str], + item_name_singular: str, + action_verb: str, +) -> bool: + """Dry-run mode: show what would be done without making changes.""" + click.echo( + f"[DRY-RUN] Would {action_verb} {len(domains_to_process)} {item_name_singular}(s):" + ) + for domain in domains_to_process: + click.echo(f" - {domain}") + click.echo("\n[DRY-RUN] No changes made.", err=True) + return True + + +def _perform_domain_operations_sequential( + ctx: click.Context, + domains_to_process: Sequence[str], + operation_callable: Callable[[str], str], + item_name_singular: str, + action_verb: str, +) -> bool: + """Sequential execution with verbose per-domain output (original behavior).""" all_successful = True failure_count = 0 for item_value in domains_to_process: @@ -61,6 +156,78 @@ def _perform_domain_operations( return all_successful +def _perform_domain_operations_parallel( + ctx: click.Context, + domains_to_process: Sequence[str], + operation_callable: Callable[[str], str], + item_name_singular: str, + action_verb: str, + concurrency: int, +) -> bool: + """Parallel execution with progress bar and summary output.""" + rate_limit_hit = threading.Event() + results = {"success": 0, "failed": 0, "skipped": 0} + errors = [] # Collect errors to print after progress bar + rate_limit_aborted = False + + total_domains = len(domains_to_process) + + with ThreadPoolExecutor(max_workers=concurrency) as executor: + futures = {} + for domain in domains_to_process: + if rate_limit_hit.is_set(): + results["skipped"] += 1 + continue + futures[executor.submit(operation_callable, domain)] = domain + + submitted_count = len(futures) + + progress_bar: Any = click.progressbar( + length=submitted_count, + label=f"Processing {item_name_singular}s", + show_pos=True, + ) + with progress_bar as bar: + for future in as_completed(futures): + domain = futures[future] + try: + future.result() + results["success"] += 1 + except RateLimitStillActiveError as e: + rate_limit_hit.set() + rate_limit_aborted = True + results["failed"] += 1 + errors.append( + f"CRITICAL: '{domain}' - persistent rate limiting: {e}" + ) + except Exception as e: + results["failed"] += 1 + errors.append(f"Failed to {action_verb} '{domain}': {e}") + bar.update(1) + + # Print any errors that occurred + for error in errors: + click.echo(error, err=True) + + # Print summary + click.echo( + f"\nCompleted: {results['success']}, " + f"Failed: {results['failed']}, " + f"Skipped: {results['skipped']} " + f"(of {total_domains} total)" + ) + + if rate_limit_aborted: + click.echo( + "Operation aborted due to persistent rate limiting. " + f"{results['skipped']} {item_name_singular}(s) were not attempted.", + err=True, + ) + ctx.exit(1) + + return results["failed"] == 0 + + @click.group() @click.version_option(__version__) @click.option( @@ -84,13 +251,27 @@ def _perform_domain_operations( help=f"Request timeout (in seconds) for API calls. Default: {DEFAULT_TIMEOUT}", show_default=True, ) +@click.option( + "--concurrency", + type=click.IntRange(1, 20), + default=DEFAULT_CONCURRENCY, + help=f"Number of concurrent API requests. Default: {DEFAULT_CONCURRENCY}", + show_default=True, +) +@click.option( + "--dry-run", + is_flag=True, + help="Show what would be done without making changes", +) @click.pass_context -def cli(ctx, retry_attempts, retry_delay, timeout): +def cli(ctx, retry_attempts, retry_delay, timeout, concurrency, dry_run): """nextdnsctl: A CLI tool for managing NextDNS profiles.""" ctx.obj = { "retry_attempts": retry_attempts, "retry_delay": retry_delay, "timeout": timeout, + "concurrency": concurrency, + "dry_run": dry_run, } @@ -129,25 +310,99 @@ def profile_list(ctx): raise click.Abort() -@cli.group("denylist") -def denylist(): - """Manage the NextDNS denylist.""" +def read_domains_from_source(source: str) -> Iterator[str]: + """ + Read domains from a file or URL, yielding one domain per line. + Handles: + - Comment lines (starting with #) + - Inline comments (e.g., "example.com # bad site") + - Empty lines and whitespace + - Streaming for memory efficiency with large files + """ + if source.startswith("http://") or source.startswith("https://"): + response = requests.get(source, stream=True, timeout=DEFAULT_TIMEOUT) + response.raise_for_status() + for line in response.iter_lines(decode_unicode=True): + if line: + domain = _parse_domain_line(line) + if domain: + yield domain + else: + with open(source, "r") as f: + for line in f: + domain = _parse_domain_line(line) + if domain: + yield domain + + +def _parse_domain_line(line: str) -> Optional[str]: + """Parse a single line, handling comments and whitespace.""" + # Strip inline comments (e.g., "example.com # bad site" -> "example.com") + line = line.split("#")[0].strip() + return line if line else None + + +# Shared command handlers for denylist/allowlist +def _handle_list_command( + ctx: click.Context, + profile: str, + list_type: str, + active_only: bool, + inactive_only: bool, +) -> None: + """Shared handler for list commands.""" + try: + profile_id = _resolve_profile_id(ctx, profile) + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_domain_list(profile_id, list_type, **api_params) + if not entries: + click.echo(f"{list_type.capitalize()} is empty.") + return -@denylist.command("add") -@click.argument("profile_id") -@click.argument("domains", nargs=-1) -@click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") -@click.pass_context -def denylist_add(ctx, profile_id, domains, inactive): - """Add domains to the NextDNS denylist.""" + if active_only: + entries = [e for e in entries if e.get("active", True)] + elif inactive_only: + entries = [e for e in entries if not e.get("active", True)] + + if not entries: + click.echo("No matching entries found.") + return + + for entry in entries: + domain = entry.get("id", "unknown") + active = entry.get("active", True) + status = "" if active else " (inactive)" + click.echo(f"{domain}{status}") + + click.echo(f"\nTotal: {len(entries)} entries", err=True) + except Exception as e: + click.echo(f"Error fetching {list_type}: {e}", err=True) + raise click.Abort() + + +def _handle_add_command( + ctx: click.Context, + profile: str, + list_type: str, + domains: Tuple[str, ...], + inactive: bool, +) -> None: + """Shared handler for add commands.""" if not domains: click.echo("No domains provided.", err=True) raise click.Abort() + profile_id = _resolve_profile_id(ctx, profile) + def operation(domain_name): - return add_to_denylist( + return add_to_domain_list( profile_id, + list_type, domain_name, active=not inactive, retries=ctx.obj["retry_attempts"], @@ -162,19 +417,23 @@ def operation(domain_name): ctx.exit(1) -@denylist.command("remove") -@click.argument("profile_id") -@click.argument("domains", nargs=-1) -@click.pass_context -def denylist_remove(ctx, profile_id, domains): - """Remove domains from the NextDNS denylist.""" +def _handle_remove_command( + ctx: click.Context, + profile: str, + list_type: str, + domains: Tuple[str, ...], +) -> None: + """Shared handler for remove commands.""" if not domains: click.echo("No domains provided.", err=True) raise click.Abort() + profile_id = _resolve_profile_id(ctx, profile) + def operation(domain_name): - return remove_from_denylist( + return remove_from_domain_list( profile_id, + list_type, domain_name, retries=ctx.obj["retry_attempts"], delay=ctx.obj["retry_delay"], @@ -188,31 +447,32 @@ def operation(domain_name): ctx.exit(1) -@denylist.command("import") -@click.argument("profile_id") -@click.argument("source") -@click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") -@click.pass_context -def denylist_import(ctx, profile_id, source, inactive): - """Import domains from a file or URL to the NextDNS denylist.""" +def _handle_import_command( + ctx: click.Context, + profile: str, + list_type: str, + source: str, + inactive: bool, +) -> None: + """Shared handler for import commands.""" + profile_id = _resolve_profile_id(ctx, profile) + try: - content = read_source(source) + # Use generator to stream file/URL and collect domains + # This avoids loading raw file content into memory + domains_to_import = list(read_domains_from_source(source)) except Exception as e: click.echo(f"Error reading source: {e}", err=True) raise click.Abort() - domains_to_import = [ - line.strip() - for line in content.splitlines() - if line.strip() and not line.strip().startswith("#") - ] if not domains_to_import: click.echo("No domains found in source.", err=True) return def operation(domain_name): - return add_to_denylist( + return add_to_domain_list( profile_id, + list_type, domain_name, active=not inactive, retries=ctx.obj["retry_attempts"], @@ -231,17 +491,168 @@ def operation(domain_name): ctx.exit(1) -def read_source(source): - """Read content from a file or URL.""" - if source.startswith("http://") or source.startswith("https://"): - response = requests.get( - source, timeout=DEFAULT_TIMEOUT - ) # Using global default timeout - response.raise_for_status() - return response.text - else: - with open(source, "r") as f: - return f.read() +def _handle_export_command( + ctx: click.Context, + profile: str, + list_type: str, + output: str, + active_only: bool, + inactive_only: bool, +) -> None: + """Shared handler for export commands.""" + try: + profile_id = _resolve_profile_id(ctx, profile) + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_domain_list(profile_id, list_type, **api_params) + if not entries: + click.echo( + f"{list_type.capitalize()} is empty, nothing to export.", err=True + ) + return + + if active_only: + entries = [e for e in entries if e.get("active", True)] + elif inactive_only: + entries = [e for e in entries if not e.get("active", True)] + + if not entries: + click.echo("No matching entries to export.", err=True) + return + + domains = [entry.get("id", "") for entry in entries if entry.get("id")] + content = "\n".join(domains) + "\n" + + if output == "-": + click.echo(content, nl=False) + else: + with open(output, "w") as f: + f.write(content) + click.echo(f"Exported {len(domains)} domains to {output}", err=True) + except Exception as e: + click.echo(f"Error exporting {list_type}: {e}", err=True) + raise click.Abort() + + +def _handle_clear_command( + ctx: click.Context, + profile: str, + list_type: str, + yes: bool, +) -> None: + """Shared handler for clear commands.""" + try: + profile_id = _resolve_profile_id(ctx, profile) + api_params = { + "retries": ctx.obj["retry_attempts"], + "delay": ctx.obj["retry_delay"], + "timeout": ctx.obj["timeout"], + } + entries = get_domain_list(profile_id, list_type, **api_params) + if not entries: + click.echo(f"{list_type.capitalize()} is already empty.") + return + + domains: List[str] = [entry["id"] for entry in entries if entry.get("id")] + if not domains: + click.echo(f"{list_type.capitalize()} is already empty.") + return + + dry_run = ctx.obj.get("dry_run", False) + if not yes and not dry_run: + click.confirm( + f"This will remove {len(domains)} domains from the {list_type}. " + "Continue?", + abort=True, + ) + + def operation(domain_name): + return remove_from_domain_list( + profile_id, + list_type, + domain_name, + retries=ctx.obj["retry_attempts"], + delay=ctx.obj["retry_delay"], + timeout=ctx.obj["timeout"], + ) + + success = _perform_domain_operations( + ctx, domains, operation, item_name_singular="domain", action_verb="remove" + ) + if not success: + ctx.exit(1) + except click.Abort: + raise + except Exception as e: + click.echo(f"Error clearing {list_type}: {e}", err=True) + raise click.Abort() + + +@cli.group("denylist") +def denylist(): + """Manage the NextDNS denylist.""" + + +@denylist.command("list") +@click.argument("profile") +@click.option("--active-only", is_flag=True, help="Show only active entries") +@click.option("--inactive-only", is_flag=True, help="Show only inactive entries") +@click.pass_context +def denylist_list(ctx, profile, active_only, inactive_only): + """List all domains in the NextDNS denylist.""" + _handle_list_command(ctx, profile, "denylist", active_only, inactive_only) + + +@denylist.command("add") +@click.argument("profile") +@click.argument("domains", nargs=-1) +@click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") +@click.pass_context +def denylist_add(ctx, profile, domains, inactive): + """Add domains to the NextDNS denylist.""" + _handle_add_command(ctx, profile, "denylist", domains, inactive) + + +@denylist.command("remove") +@click.argument("profile") +@click.argument("domains", nargs=-1) +@click.pass_context +def denylist_remove(ctx, profile, domains): + """Remove domains from the NextDNS denylist.""" + _handle_remove_command(ctx, profile, "denylist", domains) + + +@denylist.command("import") +@click.argument("profile") +@click.argument("source") +@click.option("--inactive", is_flag=True, help="Add domains as inactive (not blocked)") +@click.pass_context +def denylist_import(ctx, profile, source, inactive): + """Import domains from a file or URL to the NextDNS denylist.""" + _handle_import_command(ctx, profile, "denylist", source, inactive) + + +@denylist.command("export") +@click.argument("profile") +@click.argument("output", type=click.Path(), default="-") +@click.option("--active-only", is_flag=True, help="Export only active entries") +@click.option("--inactive-only", is_flag=True, help="Export only inactive entries") +@click.pass_context +def denylist_export(ctx, profile, output, active_only, inactive_only): + """Export denylist domains to a file (or stdout with -).""" + _handle_export_command(ctx, profile, "denylist", output, active_only, inactive_only) + + +@denylist.command("clear") +@click.argument("profile") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.pass_context +def denylist_clear(ctx, profile, yes): + """Remove all domains from the denylist.""" + _handle_clear_command(ctx, profile, "denylist", yes) @cli.group("allowlist") @@ -249,101 +660,65 @@ def allowlist(): """Manage the NextDNS allowlist.""" +@allowlist.command("list") +@click.argument("profile") +@click.option("--active-only", is_flag=True, help="Show only active entries") +@click.option("--inactive-only", is_flag=True, help="Show only inactive entries") +@click.pass_context +def allowlist_list(ctx, profile, active_only, inactive_only): + """List all domains in the NextDNS allowlist.""" + _handle_list_command(ctx, profile, "allowlist", active_only, inactive_only) + + @allowlist.command("add") -@click.argument("profile_id") +@click.argument("profile") @click.argument("domains", nargs=-1) @click.option("--inactive", is_flag=True, help="Add domains as inactive (not allowed)") @click.pass_context -def allowlist_add(ctx, profile_id, domains, inactive): +def allowlist_add(ctx, profile, domains, inactive): """Add domains to the NextDNS allowlist.""" - if not domains: - click.echo("No domains provided.", err=True) - raise click.Abort() - - def operation(domain_name): - return add_to_allowlist( - profile_id, - domain_name, - active=not inactive, - retries=ctx.obj["retry_attempts"], - delay=ctx.obj["retry_delay"], - timeout=ctx.obj["timeout"], - ) - - success = _perform_domain_operations( - ctx, domains, operation, item_name_singular="domain", action_verb="add" - ) - if not success: - ctx.exit(1) + _handle_add_command(ctx, profile, "allowlist", domains, inactive) @allowlist.command("remove") -@click.argument("profile_id") +@click.argument("profile") @click.argument("domains", nargs=-1) @click.pass_context -def allowlist_remove(ctx, profile_id, domains): +def allowlist_remove(ctx, profile, domains): """Remove domains from the NextDNS allowlist.""" - if not domains: - click.echo("No domains provided.", err=True) - raise click.Abort() - - def operation(domain_name): - return remove_from_allowlist( - profile_id, - domain_name, - retries=ctx.obj["retry_attempts"], - delay=ctx.obj["retry_delay"], - timeout=ctx.obj["timeout"], - ) - - success = _perform_domain_operations( - ctx, domains, operation, item_name_singular="domain", action_verb="remove" - ) - if not success: - ctx.exit(1) + _handle_remove_command(ctx, profile, "allowlist", domains) @allowlist.command("import") -@click.argument("profile_id") +@click.argument("profile") @click.argument("source") @click.option("--inactive", is_flag=True, help="Add domains as inactive (not allowed)") @click.pass_context -def allowlist_import(ctx, profile_id, source, inactive): +def allowlist_import(ctx, profile, source, inactive): """Import domains from a file or URL to the NextDNS allowlist.""" - try: - content = read_source(source) - except Exception as e: - click.echo(f"Error reading source: {e}", err=True) - raise click.Abort() + _handle_import_command(ctx, profile, "allowlist", source, inactive) - domains_to_import = [ - line.strip() - for line in content.splitlines() - if line.strip() and not line.strip().startswith("#") - ] - if not domains_to_import: - click.echo("No domains found in source.", err=True) - return - def operation(domain_name): - return add_to_allowlist( - profile_id, - domain_name, - active=not inactive, - retries=ctx.obj["retry_attempts"], - delay=ctx.obj["retry_delay"], - timeout=ctx.obj["timeout"], - ) - - success = _perform_domain_operations( - ctx, - domains_to_import, - operation, - item_name_singular="domain", - action_verb="add", +@allowlist.command("export") +@click.argument("profile") +@click.argument("output", type=click.Path(), default="-") +@click.option("--active-only", is_flag=True, help="Export only active entries") +@click.option("--inactive-only", is_flag=True, help="Export only inactive entries") +@click.pass_context +def allowlist_export(ctx, profile, output, active_only, inactive_only): + """Export allowlist domains to a file (or stdout with -).""" + _handle_export_command( + ctx, profile, "allowlist", output, active_only, inactive_only ) - if not success: - ctx.exit(1) + + +@allowlist.command("clear") +@click.argument("profile") +@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@click.pass_context +def allowlist_clear(ctx, profile, yes): + """Remove all domains from the allowlist.""" + _handle_clear_command(ctx, profile, "allowlist", yes) if __name__ == "__main__": diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..5f8ffcb --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,14 @@ +# Development and testing dependencies +-r requirements.txt + +# Testing +pytest>=8.0 +pytest-mock>=3.12 +requests-mock>=1.11 + +# Linting (already in CI, but good to have locally) +flake8>=7.0 + +# Type checking (recommended in test strategy) +mypy>=1.0 +types-requests>=2.31 diff --git a/setup.py b/setup.py index 4a9da46..ea46d87 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,19 @@ +import re from setuptools import setup, find_packages + +def get_version(): + """Read version from nextdnsctl/__init__.py without importing.""" + with open("nextdnsctl/__init__.py") as f: + match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', f.read(), re.M) + if match: + return match.group(1) + raise RuntimeError("Version not found") + + setup( name="nextdnsctl", - version="0.2.0", + version=get_version(), packages=find_packages(), install_requires=[ "requests", @@ -21,15 +32,11 @@ license="MIT", url="https://github.com/danielmeint/nextdnsctl", keywords=["nextdns", "cli", "dns", "security", "networking"], - python_requires=">=3.6", + python_requires=">=3.10", classifiers=[ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e1faa3e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,54 @@ +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def runner(): + """Provides a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_api_key(monkeypatch): + """Sets a fake API key via environment variable.""" + monkeypatch.setenv("NEXTDNS_API_KEY", "fake-key-123") + + +@pytest.fixture +def mock_profiles_response(): + """Standard mock response for the profiles endpoint.""" + return { + "data": [ + {"id": "abc1234", "name": "My Profile"}, + {"id": "xyz9876", "name": "Kids Profile"}, + ] + } + + +@pytest.fixture +def mock_denylist_response(): + """Standard mock response for a denylist.""" + return { + "data": [ + {"id": "bad-domain.com", "active": True}, + {"id": "inactive-domain.com", "active": False}, + {"id": "another-bad.net", "active": True}, + ] + } + + +@pytest.fixture +def mock_allowlist_response(): + """Standard mock response for an allowlist.""" + return { + "data": [ + {"id": "good-domain.com", "active": True}, + {"id": "trusted-site.org", "active": True}, + ] + } + + +@pytest.fixture +def mock_empty_list_response(): + """Mock response for an empty list.""" + return {"data": []} diff --git a/tests/test_api_resilience.py b/tests/test_api_resilience.py new file mode 100644 index 0000000..4bc645b --- /dev/null +++ b/tests/test_api_resilience.py @@ -0,0 +1,166 @@ +"""Tests for API error handling and resilience.""" + +import pytest +from unittest.mock import Mock + +from nextdnsctl.api import api_call, RateLimitStillActiveError + + +class TestRetryOn500: + """Tests for retry behavior on server errors.""" + + def test_retries_on_500_then_succeeds(self, mocker): + """Should retry on 500 errors and succeed when server recovers.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mocker.patch("nextdnsctl.api.time.sleep") # Don't actually sleep + + # First two calls fail with 500, third succeeds + mock_response_fail = Mock() + mock_response_fail.status_code = 500 + + mock_response_ok = Mock() + mock_response_ok.status_code = 200 + mock_response_ok.json.return_value = {"success": True} + + mock_req.side_effect = [mock_response_fail, mock_response_fail, mock_response_ok] + + result = api_call("GET", "test", retries=3) + + assert result == {"success": True} + assert mock_req.call_count == 3 + + def test_fails_after_exhausting_retries(self, mocker): + """Should raise exception after all retries exhausted.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mocker.patch("nextdnsctl.api.time.sleep") + + mock_response = Mock() + mock_response.status_code = 500 + mock_response.json.return_value = {"errors": [{"detail": "Internal error"}]} + mock_req.return_value = mock_response + + with pytest.raises(Exception, match="Internal error"): + api_call("GET", "test", retries=2) + + assert mock_req.call_count == 3 # Initial + 2 retries + + +class TestRateLimiting: + """Tests for rate limit (429) handling.""" + + def test_respects_retry_after_header(self, mocker): + """Should sleep for Retry-After seconds when rate limited.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mock_sleep = mocker.patch("nextdnsctl.api.time.sleep") + + # First call: 429 with Retry-After, second call: success + mock_rate_limited = Mock() + mock_rate_limited.status_code = 429 + mock_rate_limited.headers = {"Retry-After": "5"} + + mock_ok = Mock() + mock_ok.status_code = 200 + mock_ok.json.return_value = {"data": []} + + mock_req.side_effect = [mock_rate_limited, mock_ok] + + api_call("GET", "test", retries=1) + + mock_sleep.assert_called_with(5) + + def test_uses_default_pause_without_retry_after(self, mocker): + """Should use default pause when no Retry-After header.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mock_sleep = mocker.patch("nextdnsctl.api.time.sleep") + + mock_rate_limited = Mock() + mock_rate_limited.status_code = 429 + mock_rate_limited.headers = {} # No Retry-After + + mock_ok = Mock() + mock_ok.status_code = 200 + mock_ok.json.return_value = {"data": []} + + mock_req.side_effect = [mock_rate_limited, mock_ok] + + api_call("GET", "test", retries=1) + + # Default pause is 60 seconds (DEFAULT_PATIENT_RETRY_PAUSE_SECONDS) + mock_sleep.assert_called_with(60) + + def test_raises_rate_limit_error_after_exhaustion(self, mocker): + """Should raise RateLimitStillActiveError when rate limit persists.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mocker.patch("nextdnsctl.api.time.sleep") + + mock_response = Mock() + mock_response.status_code = 429 + mock_response.headers = {} # No Retry-After + mock_req.return_value = mock_response + + with pytest.raises(RateLimitStillActiveError): + api_call("GET", "test", retries=2) + + +class TestNetworkErrors: + """Tests for network error handling.""" + + def test_retries_on_network_error(self, mocker): + """Should retry on network exceptions.""" + from requests.exceptions import ConnectionError + + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + mocker.patch("nextdnsctl.api.time.sleep") + + mock_ok = Mock() + mock_ok.status_code = 200 + mock_ok.json.return_value = {"success": True} + + # First two calls fail with network error, third succeeds + mock_req.side_effect = [ + ConnectionError("Connection refused"), + ConnectionError("Connection refused"), + mock_ok, + ] + + result = api_call("GET", "test", retries=3) + + assert result == {"success": True} + assert mock_req.call_count == 3 + + +class TestSuccessResponses: + """Tests for successful response handling.""" + + def test_handles_204_no_content(self, mocker): + """Should return None for 204 responses.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + + mock_response = Mock() + mock_response.status_code = 204 + mock_req.return_value = mock_response + + result = api_call("DELETE", "test/resource") + + assert result is None + + def test_handles_201_created(self, mocker): + """Should handle 201 Created responses.""" + mock_req = mocker.patch("nextdnsctl.api.requests.request") + mocker.patch("nextdnsctl.api.load_api_key", return_value="fake-key") + + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": "new-resource"} + mock_req.return_value = mock_response + + result = api_call("POST", "test") + + assert result == {"id": "new-resource"} diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py new file mode 100644 index 0000000..9ea63ed --- /dev/null +++ b/tests/test_cli_integration.py @@ -0,0 +1,218 @@ +"""Integration tests for CLI commands with mocked API.""" + +import requests_mock as rm + +from nextdnsctl.nextdnsctl import cli +from nextdnsctl.api import API_BASE + + +class TestProfileList: + """Tests for profile-list command.""" + + def test_lists_profiles(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + + result = runner.invoke(cli, ["profile-list"]) + + assert result.exit_code == 0 + assert "abc1234: My Profile" in result.output + assert "xyz9876: Kids Profile" in result.output + + def test_empty_profiles(self, runner, mock_api_key): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json={"data": []}) + + result = runner.invoke(cli, ["profile-list"]) + + assert result.exit_code == 0 + assert "No profiles found" in result.output + + +class TestDenylistCommands: + """Tests for denylist subcommands.""" + + def test_denylist_list(self, runner, mock_api_key, mock_profiles_response, mock_denylist_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.get(f"{API_BASE}profiles/abc1234/denylist", json=mock_denylist_response) + + result = runner.invoke(cli, ["denylist", "list", "abc1234"]) + + assert result.exit_code == 0 + assert "bad-domain.com" in result.output + assert "inactive-domain.com (inactive)" in result.output + + def test_denylist_list_by_name(self, runner, mock_api_key, mock_profiles_response, mock_denylist_response): + """Profile resolution by name should work.""" + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.get(f"{API_BASE}profiles/abc1234/denylist", json=mock_denylist_response) + + result = runner.invoke(cli, ["denylist", "list", "My Profile"]) + + assert result.exit_code == 0 + assert "bad-domain.com" in result.output + + def test_denylist_add(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "bad.com", "active": True}) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "add", "abc1234", "bad.com"]) + + assert result.exit_code == 0 + assert adapter.called + assert adapter.last_request.json() == {"id": "bad.com", "active": True} + + def test_denylist_add_inactive(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "bad.com", "active": False}) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "add", "abc1234", "--inactive", "bad.com"]) + + assert result.exit_code == 0 + assert adapter.last_request.json() == {"id": "bad.com", "active": False} + + def test_denylist_remove(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.delete(f"{API_BASE}profiles/abc1234/denylist/bad.com", status_code=204) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "remove", "abc1234", "bad.com"]) + + assert result.exit_code == 0 + assert adapter.called + + +class TestDryRun: + """Tests for --dry-run mode.""" + + def test_denylist_add_dry_run_makes_no_requests(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + # No POST mock - if it tries to POST, it will fail + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", status_code=500) + + result = runner.invoke(cli, ["--dry-run", "denylist", "add", "abc1234", "bad.com"]) + + assert result.exit_code == 0 + assert "[DRY-RUN]" in result.output + assert "bad.com" in result.output + assert not adapter.called # No actual request made + + +class TestProfileResolution: + """Tests for profile ID/name resolution.""" + + def test_profile_not_found(self, runner, mock_api_key, mock_profiles_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + + result = runner.invoke(cli, ["denylist", "list", "Nonexistent Profile"]) + + assert result.exit_code != 0 + assert "not found" in result.output + + def test_case_insensitive_name_match(self, runner, mock_api_key, mock_profiles_response, mock_denylist_response): + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.get(f"{API_BASE}profiles/abc1234/denylist", json=mock_denylist_response) + + result = runner.invoke(cli, ["denylist", "list", "my profile"]) # lowercase + + assert result.exit_code == 0 + assert "bad-domain.com" in result.output + + +class TestAuthCommand: + """Tests for auth command.""" + + def test_auth_saves_api_key(self, runner, tmp_path, monkeypatch): + """Auth command should save API key to config file.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + + monkeypatch.setattr("nextdnsctl.config.CONFIG_DIR", str(config_dir)) + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + # Also patch in nextdnsctl.py since it imports from config + monkeypatch.setattr("nextdnsctl.nextdnsctl.save_api_key", + lambda k: __import__('nextdnsctl.config', fromlist=['save_api_key']).save_api_key(k)) + + result = runner.invoke(cli, ["auth", "my-test-key-123"]) + + assert result.exit_code == 0 + assert "saved successfully" in result.output + assert config_file.exists() + + def test_auth_without_key_fails(self, runner): + """Auth command should fail when no key provided.""" + result = runner.invoke(cli, ["auth"]) + + assert result.exit_code != 0 + assert "Missing argument" in result.output + + +class TestImportCommand: + """Tests for import command.""" + + def test_import_from_file(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """Import should read domains from a file and add them.""" + # Create a test file with domains + domains_file = tmp_path / "domains.txt" + domains_file.write_text("bad1.com\nbad2.com\n# comment line\nbad3.com # inline comment\n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "test", "active": True}) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "import", "abc1234", str(domains_file)]) + + assert result.exit_code == 0 + # Should have made 3 POST requests (comment lines excluded) + assert adapter.call_count == 3 + # Verify the domains that were sent + requests_made = [req.json()["id"] for req in adapter.request_history] + assert "bad1.com" in requests_made + assert "bad2.com" in requests_made + assert "bad3.com" in requests_made + + def test_import_empty_file(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """Import should handle empty files gracefully.""" + domains_file = tmp_path / "empty.txt" + domains_file.write_text("# only comments\n\n \n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + + result = runner.invoke(cli, ["denylist", "import", "abc1234", str(domains_file)]) + + assert "No domains found" in result.output + + def test_import_nonexistent_file(self, runner, mock_api_key, mock_profiles_response): + """Import should fail gracefully for nonexistent files.""" + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + + result = runner.invoke(cli, ["denylist", "import", "abc1234", "/nonexistent/file.txt"]) + + assert result.exit_code != 0 + assert "Error reading source" in result.output + + def test_import_dry_run(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """Import with --dry-run should not make API calls.""" + domains_file = tmp_path / "domains.txt" + domains_file.write_text("bad1.com\nbad2.com\n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + adapter = m.post(f"{API_BASE}profiles/abc1234/denylist", status_code=500) + + result = runner.invoke(cli, ["--dry-run", "denylist", "import", "abc1234", str(domains_file)]) + + assert result.exit_code == 0 + assert "[DRY-RUN]" in result.output + assert "bad1.com" in result.output + assert "bad2.com" in result.output + assert not adapter.called diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..d7ece4d --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,70 @@ +"""Tests for concurrency/parallelism validation.""" + +import requests_mock as rm + +from nextdnsctl.nextdnsctl import cli +from nextdnsctl.api import API_BASE + + +class TestConcurrency: + """Tests to verify parallel execution works correctly.""" + + def test_parallel_mode_uses_summary_output(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """ + Verify that with concurrency > 1, parallel mode is used. + + Parallel mode shows a summary with "Completed: X, Failed: Y, Skipped: Z" + while sequential mode shows per-domain output like "Added domain.com". + """ + domains_file = tmp_path / "domains.txt" + domains_file.write_text("d1.com\nd2.com\nd3.com\n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "test", "active": True}) + + result = runner.invoke(cli, ["--concurrency", "3", "denylist", "import", "abc1234", str(domains_file)]) + + assert result.exit_code == 0 + # Parallel mode shows summary format + assert "Completed:" in result.output + assert "Failed:" in result.output + # Should NOT show per-domain "Added" messages (that's sequential mode) + assert "Added d1.com" not in result.output + + def test_sequential_mode_shows_per_domain_output(self, runner, mock_api_key, mock_profiles_response, tmp_path): + """ + Verify that with concurrency=1, sequential mode is used. + + Sequential mode shows per-domain output like "Added domain.com as active" + while parallel mode shows a summary format. + """ + domains_file = tmp_path / "domains.txt" + domains_file.write_text("d1.com\nd2.com\nd3.com\n") + + with rm.Mocker() as m: + m.get(f"{API_BASE}profiles", json=mock_profiles_response) + m.post(f"{API_BASE}profiles/abc1234/denylist", json={"id": "test", "active": True}) + + result = runner.invoke(cli, ["--concurrency", "1", "denylist", "import", "abc1234", str(domains_file)]) + + assert result.exit_code == 0 + # Sequential mode shows per-domain messages + assert "Added d1.com" in result.output + assert "Added d2.com" in result.output + assert "Added d3.com" in result.output + # Should NOT show parallel summary format + assert "Completed:" not in result.output + + def test_concurrency_respects_max_limit(self, runner): + """Concurrency option should reject values > 20.""" + result = runner.invoke(cli, ["--concurrency", "21", "profile-list"]) + + assert result.exit_code != 0 + assert "21" in result.output or "range" in result.output.lower() + + def test_concurrency_respects_min_limit(self, runner): + """Concurrency option should reject values < 1.""" + result = runner.invoke(cli, ["--concurrency", "0", "profile-list"]) + + assert result.exit_code != 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a442da6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,99 @@ +"""Unit tests for configuration handling.""" + +import json +import os +import stat +import pytest + +from nextdnsctl.config import save_api_key, load_api_key, ENV_VAR_NAME + + +class TestSaveApiKey: + """Tests for save_api_key function.""" + + def test_creates_config_file(self, tmp_path, monkeypatch): + """Verify save_api_key creates the config file.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + + monkeypatch.setattr("nextdnsctl.config.CONFIG_DIR", str(config_dir)) + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + + save_api_key("test-key-123") + + assert config_file.exists() + with open(config_file) as f: + data = json.load(f) + assert data["api_key"] == "test-key-123" + + def test_file_permissions_are_secure(self, tmp_path, monkeypatch): + """Verify config file has 600 permissions (owner read/write only).""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + + monkeypatch.setattr("nextdnsctl.config.CONFIG_DIR", str(config_dir)) + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + + save_api_key("test-key-123") + + file_mode = os.stat(config_file).st_mode + # Check that only owner has read/write (600 = S_IRUSR | S_IWUSR) + assert file_mode & 0o777 == stat.S_IRUSR | stat.S_IWUSR + + +class TestLoadApiKey: + """Tests for load_api_key function.""" + + def test_env_var_takes_precedence(self, tmp_path, monkeypatch): + """Environment variable should override config file.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + config_dir.mkdir() + + # Create config file with one key + with open(config_file, "w") as f: + json.dump({"api_key": "file-key"}, f) + + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + monkeypatch.setenv(ENV_VAR_NAME, "env-key") + + assert load_api_key() == "env-key" + + def test_falls_back_to_config_file(self, tmp_path, monkeypatch): + """Should use config file when env var is not set.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + config_dir.mkdir() + + with open(config_file, "w") as f: + json.dump({"api_key": "file-key"}, f) + + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + monkeypatch.delenv(ENV_VAR_NAME, raising=False) + + assert load_api_key() == "file-key" + + def test_raises_when_no_config_exists(self, tmp_path, monkeypatch): + """Should raise ValueError when no API key is found.""" + config_file = tmp_path / "nonexistent" / "config.json" + + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + monkeypatch.delenv(ENV_VAR_NAME, raising=False) + + with pytest.raises(ValueError, match="No API key found"): + load_api_key() + + def test_raises_when_config_missing_api_key(self, tmp_path, monkeypatch): + """Should raise ValueError when config file lacks api_key.""" + config_dir = tmp_path / ".nextdnsctl" + config_file = config_dir / "config.json" + config_dir.mkdir() + + with open(config_file, "w") as f: + json.dump({"other_key": "value"}, f) + + monkeypatch.setattr("nextdnsctl.config.CONFIG_FILE", str(config_file)) + monkeypatch.delenv(ENV_VAR_NAME, raising=False) + + with pytest.raises(ValueError, match="Invalid config file"): + load_api_key() diff --git a/tests/test_parsing.py b/tests/test_parsing.py new file mode 100644 index 0000000..13a0a25 --- /dev/null +++ b/tests/test_parsing.py @@ -0,0 +1,41 @@ +"""Unit tests for domain parsing logic.""" + +from nextdnsctl.nextdnsctl import _parse_domain_line + + +class TestParseDomainLine: + """Tests for _parse_domain_line function.""" + + def test_simple_domain(self): + assert _parse_domain_line("example.com") == "example.com" + + def test_domain_with_inline_comment(self): + assert _parse_domain_line("example.com # comment") == "example.com" + + def test_domain_with_inline_comment_no_space(self): + assert _parse_domain_line("example.com#comment") == "example.com" + + def test_pure_comment(self): + assert _parse_domain_line("# pure comment") is None + + def test_comment_with_leading_spaces(self): + assert _parse_domain_line(" # comment") is None + + def test_empty_line(self): + assert _parse_domain_line("") is None + + def test_whitespace_only(self): + assert _parse_domain_line(" ") is None + + def test_domain_with_trailing_whitespace(self): + assert _parse_domain_line("example.com ") == "example.com" + + def test_domain_with_leading_whitespace(self): + assert _parse_domain_line(" example.com") == "example.com" + + def test_subdomain(self): + assert _parse_domain_line("sub.example.com") == "sub.example.com" + + def test_complex_inline_comment(self): + # Only the first # should trigger comment stripping + assert _parse_domain_line("domain.com # bad site # really bad") == "domain.com"