diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 49f9ff9..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,94 +0,0 @@ -version: 2.1 -jobs: - test-amd64: - machine: - image: ubuntu-2004:202201-02 - resource_class: medium - steps: - - checkout - - deploy: - name: "Test in amd64 machine" - command: | - make test - test-arm64: - machine: - image: ubuntu-2004:202201-02 - resource_class: arm.medium - steps: - - checkout - - deploy: - name: "Test in arm64 machine" - command: | - make test - push-amd64: - machine: - image: ubuntu-2004:202201-02 - resource_class: medium - steps: - - checkout - - deploy: - name: "Upload amd64 image to registry" - command: | - export VERSION="${CIRCLE_TAG}-amd64" - make build-docker - echo "${DOCKERHUB_PASSWORD}" | docker login -u pablogcaldito --password-stdin - docker push pablogcaldito/ipwarn:"${VERSION}" - docker tag pablogcaldito/ipwarn:"${VERSION}" pablogcaldito/ipwarn:latest-amd64 - docker push pablogcaldito/ipwarn:latest-amd64 - push-arm64: - machine: - image: ubuntu-2004:202201-02 - resource_class: arm.medium - steps: - - checkout - - deploy: - name: "Upload arm64 image to registry" - command: | - export VERSION="${CIRCLE_TAG}-arm64" - make build-docker - echo "${DOCKERHUB_PASSWORD}" | docker login -u pablogcaldito --password-stdin - docker push pablogcaldito/ipwarn:"${VERSION}" - docker tag pablogcaldito/ipwarn:"${VERSION}" pablogcaldito/ipwarn:latest-arm64 - docker push pablogcaldito/ipwarn:latest-arm64 - -workflows: - test: - jobs: - - test-amd64 - - test-arm64 - push: - jobs: - - test-amd64: - filters: - branches: - ignore: /.*/ - tags: - only: /^v.*/ - - test-arm64: - filters: - branches: - ignore: /.*/ - tags: - only: /^v.*/ - - push-amd64: - context: - - docker - filters: - branches: - ignore: /.*/ - tags: - only: /^v.*/ - requires: - - test-amd64 - - test-arm64 - - push-arm64: - context: - - docker - filters: - branches: - ignore: /.*/ - tags: - only: /^v.*/ - requires: - - test-amd64 - - test-arm64 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..80211f4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,59 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Git +.git/ +.gitignore + +# Documentation +CHANGELOG.md +CODE_OF_CONDUCT.md +CONTRIBUTING.md +DEVELOPMENT.md +LICENSE +README.md + +# Old bash script +*.sh.bak + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# uv +uv.lock + +# Linter caches +.ruff_cache/ +.mypy_cache/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4a08cf8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release Docker image + +on: + push: + tags: + - "*" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (use tag as image tag) + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4014077 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Test + +on: + push: + branches: [main, master, develop] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + run: pip install uv + + - name: Create virtual environment and install dependencies + run: uv venv && uv pip install -e ".[dev]" + + - name: Run tests + run: make test + + - name: Run linters + run: make lint + + - name: Run linters + run: make lint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83ef378 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +venv/ +env/ +ENV/ +.venv/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.coverage.* + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fbb94..feb4f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,51 @@ # Changelog + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [2.0.0] - 2025-02-08 + +### Added +- Complete rewrite in Python 3.12+ for better maintainability +- Modular DNS provider architecture for easy extensibility +- Porkbun DNS provider support +- Multiple IP checker services with automatic failover: + - icanhazip.com + - ipify.org + - ifconfig.me +- Structured logging with configurable log levels +- `--once` and `--dry-run` CLI flags for testing +- Comprehensive test suite with pytest +- Support for multiple DNS providers simultaneously +- Base classes for DNS providers and notifiers + +### Changed +- Uses `requests` library for HTTP queries instead of curl +- Configuration file format extended (backward compatible with v1.x) +- Docker image now Python-based (smaller, more maintainable) +- Improved error handling and retry logic +- Better logging and debugging capabilities + +### Deprecated +- Bash version is no longer maintained + +### Removed +- Bash implementation (replaced by Python) + +### Fixed +- Better handling of network failures and API errors +- More robust IP validation +- Cleaner separation of concerns between providers -## [v1.0.1](https://github.com/caldito/ipwarn/tree/v1.0.1) - 2022-12-29 +## [v1.0.1] - 2022-12-29 [Full changelog](https://github.com/caldito/ipwarn/compare/v1.0.0...v1.0.1) ### Fixed - Docker instructions in README.md - Fix pipelines for releasing multi-arch docker images -## [v1.0.0](https://github.com/caldito/ipwarn/tree/v1.0.0) - 2022-12-28 +## [v1.0.0] - 2022-12-28 [Full changelog](https://github.com/caldito/ipwarn/compare/v0.1.0...v1.0.0) ### Added - Refactor to a design in which the program runs indefinitely in the background @@ -23,6 +56,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Systemd easy install script - Changelog -## [v0.1.0](https://github.com/caldito/ipwarn/tree/v0.1.0) +## [v0.1.0] ### Added - Initial version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de51010..f2d8052 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,18 @@ email, or any other method with the owners of this repository before making a ch Please note we have a code of conduct, please follow it in all your interactions with the project. +## Development Setup + +For detailed development setup information, see [DEVELOPMENT.md](https://github.com/caldito/ipwarn/blob/master/DEVELOPMENT.md). + +Quick start: +```bash +make setup # Setup development environment +make test # Run tests +make format # Format code +make lint # Run linters +``` + ## Pull Request Process - Pull requests are submitted to the `develop` branch @@ -13,4 +25,4 @@ Please note we have a code of conduct, please follow it in all your interactions - Once merged into develop the project maintainers will create a new release shortly ## Code of Conduct -Please read [CODE_OF_CONDUCT.md](https://github.com/pablogcaldito/ipwarn/blob/master/CODE_OF_CONDUCT.md) for details on our code of conduct. +Please read [CODE_OF_CONDUCT.md](https://github.com/caldito/ipwarn/blob/master/CODE_OF_CONDUCT.md) for details on our code of conduct. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..67cb916 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,186 @@ +# Development Guide + +## Prerequisites + +- Python 3.12 or higher +- [uv](https://github.com/astral-sh/uv) - Fast Python package manager + +## Quick Start + +```bash +# Clone the repository +git clone https://github.com/caldito/ipwarn.git +cd ipwarn + +# Setup development environment (creates .venv and installs dependencies) +make setup + +# Run tests +make test + +# Format code +make format + +# Run linters +make lint +``` + +## Architecture + +### Project Structure + +``` +ipwarn/ +├── ipwarn/ +│ ├── __init__.py +│ ├── __main__.py # CLI entry point and main application loop +│ ├── config.py # Configuration file parser +│ ├── ip_checkers.py # IP detection with failover +│ ├── logger.py # Logging configuration +│ ├── dns_providers/ # DNS provider implementations +│ │ ├── base.py # Abstract base class +│ │ ├── godaddy.py # GoDaddy provider +│ │ └── porkbun.py # Porkbun provider +│ └── notifiers/ # Notification handlers +│ ├── base.py # Abstract base class +│ └── telegram.py # Telegram notifier +├── tests/ # Test suite +│ ├── conftest.py +│ ├── test_config.py +│ ├── test_dns_providers.py +│ └── test_ip_checkers.py +└── pyproject.toml # Project configuration +``` + +### Core Components + +#### Configuration Parser (`config.py`) +- Parses shell-like `.conf` files +- Supports quoted values and comments +- Provides type-safe access methods: `get()`, `get_bool()`, `get_int()`, `get_list()` + +#### IP Checker (`ip_checkers.py`) +- Tries multiple IP checking services with automatic failover +- Validates IPv4 addresses +- Configurable timeout per service + +#### DNS Providers (`dns_providers/`) +- Abstract `BaseDNSProvider` class for extensibility +- Check if update is needed before making API calls +- Proper error handling and logging + +#### Notifiers (`notifiers/`) +- Abstract `BaseNotifier` class for extensibility +- Send notifications on successful IP updates + +## Adding a New DNS Provider + +1. Create a new file in `ipwarn/dns_providers/` (e.g., `cloudflare.py`) + +2. Inherit from `BaseDNSProvider` and implement required methods: + +```python +from ipwarn.dns_providers.base import BaseDNSProvider, DNSProviderError + +class CloudflareProvider(BaseDNSProvider): + def _validate_config(self) -> None: + """Validate provider-specific configuration.""" + if not self.config.get("CF_API_TOKEN"): + raise DNSProviderError("Cloudflare API token is required") + + def get_current_ip(self, domain: str, record_name: str, record_type: str) -> str: + """Get current IP for a DNS record.""" + # Implement API call to get current IP + pass + + def update_ip(self, domain: str, record_name: str, record_type: str, ip: str) -> bool: + """Update DNS record with new IP.""" + # Implement API call to update IP + pass +``` + +3. Add configuration options to `config.py` (if needed) + +4. Update `__main__.py` to instantiate your provider when enabled + +## Adding a New Notifier + +1. Create a new file in `ipwarn/notifiers/` (e.g., `email.py`) + +2. Inherit from `BaseNotifier` and implement required methods: + +```python +from ipwarn.notifiers.base import BaseNotifier, NotifierError + +class EmailNotifier(BaseNotifier): + def _validate_config(self) -> None: + """Validate notifier-specific configuration.""" + if not self.config.get("EMAIL_TO"): + raise NotifierError("Email recipient is required") + + def send(self, message: str) -> None: + """Send notification message.""" + # Implement email sending logic + pass +``` + +3. Add configuration options to `config.py` + +4. Update `__main__.py` to instantiate your notifier when enabled + +## Running Tests + +```bash +# Run all tests +make test + +# Run specific test file +.venv/bin/pytest tests/test_config.py -v + +# Run with coverage +.venv/bin/pytest --cov=ipwarn tests/ +``` + +## Code Quality + +```bash +# Format code +make format + +# Run linters +make lint + +# Auto-fix linting issues where possible +.venv/bin/ruff check --fix ipwarn/ tests/ +``` + +## Testing Locally + +```bash +# Run once with dry-run (no actual updates) +make run-once + +# Run with custom config +.venv/bin/python -m ipwarn --config path/to/config.conf --dry-run --once +``` + +## Release Process + +1. Update version in `pyproject.toml` +2. Update `CHANGELOG.md` +3. Commit and push changes +4. Tag the release: `git tag v2.0.1` +5. Push tag: `git push origin v2.0.1` +6. GitHub Actions will automatically build and push Docker images to ghcr.io + +## CI/CD + +The project uses GitHub Actions for CI/CD: + +- **Test workflow** (.github/workflows/test.yml): Runs on every push/PR to main/master/develop + - Installs dependencies with uv + - Runs tests and linters + +- **Release workflow** (.github/workflows/release.yml): Runs on version tags (v*) + - Builds multi-arch Docker images (amd64, arm64) + - Pushes images to GitHub Container Registry (ghcr.io) diff --git a/Dockerfile b/Dockerfile index a87fa43..c00dc71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,30 @@ -FROM ubuntu -MAINTAINER pablo@caldito.me -RUN apt update && apt install -y curl -COPY ipwarn /usr/local/bin/ -RUN mkdir /etc/ipwarn -COPY config/ipwarn.conf /etc/ipwarn/ipwarn.conf -CMD ["/usr/local/bin/ipwarn"] +FROM python:3.12-slim + +LABEL maintainer="pablo@caldito.me" + +# Install uv for dependency management +RUN pip install --no-cache-dir uv + +# Create application directory +WORKDIR /app + +# Copy application files +COPY pyproject.toml /app/ +COPY ipwarn/ /app/ipwarn/ + +# Install dependencies and package using uv +RUN uv venv /opt/venv && \ + . /opt/venv/bin/activate && \ + uv pip install /app + +# Add venv to PATH +ENV PATH="/opt/venv/bin:$PATH" + +# Create config directory +RUN mkdir -p /etc/ipwarn + +# Copy default config +COPY config/ipwarn.conf.example /etc/ipwarn/ipwarn.conf + +# Set entrypoint +CMD ["ipwarn", "--config", "/etc/ipwarn/ipwarn.conf"] diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..a026f07 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,215 @@ +# ipwarn v2.0.0 - Python Port Implementation Summary + +## Overview + +ipwarn has been successfully ported from Bash to Python 3.12+ with a modular architecture supporting multiple DNS providers and IP checkers. + +## Key Features Implemented + +### 1. Core Architecture +- **Modular Design**: Clean separation of concerns with base classes for extensibility +- **DNS Providers**: Abstract base class (`BaseDNSProvider`) for easy addition of new providers +- **Notifiers**: Abstract base class (`BaseNotifier`) for notification services +- **IP Checkers**: Failover mechanism across multiple IP checking services + +### 2. DNS Providers +- **GoDaddy**: Full implementation using GoDaddy API +- **Porkbun**: Full implementation using Porkbun API + - Uses `retrieveByNameType` endpoint to get current IP + - Uses `editByNameType` endpoint to update records + - Supports custom TTL settings + +### 3. IP Checkers +Three IP checking services with automatic failover: +- icanhazip.com (legacy) +- ipify.org +- ifconfig.me + +Features: +- Configurable order and timeout +- Automatic failover when one checker fails +- IP validation + +### 4. Notification +- **Telegram**: Full implementation using Telegram Bot API +- Easy to extend with additional notifiers + +### 5. Configuration +- **Backward Compatible**: Maintains v1.x config file format +- New options added: + - `IP_CHECKERS`: Comma-separated list of IP checker services + - `UPDATE_PORKBUN`, `PB_DOMAIN`, `PB_RECORD_NAME`, `PB_RECORD_TYPE`, `PB_API_KEY`, `PB_SECRET_API_KEY`, `PB_TTL` + - `LOG_LEVEL`: Configurable logging level (DEBUG, INFO, WARNING, ERROR) + +### 6. CLI Options +- `-h, --help`: Show help message +- `-v, --version`: Show version +- `-c, --config`: Custom config file path +- `--once`: Run once and exit (for testing) +- `--dry-run`: Don't actually update DNS records + +## Project Structure + +``` +ipwarn/ +├── ipwarn/ +│ ├── __init__.py # Package initialization +│ ├── __main__.py # CLI entry point +│ ├── config.py # Config file parser (backward compatible) +│ ├── ip_checkers.py # IP checker implementations +│ ├── logger.py # Logging configuration +│ ├── dns_providers/ # DNS provider implementations +│ │ ├── __init__.py +│ │ ├── base.py # Abstract base class +│ │ ├── godaddy.py # GoDaddy provider +│ │ └── porkbun.py # Porkbun provider +│ └── notifiers/ # Notification handlers +│ ├── __init__.py +│ ├── base.py # Abstract base class +│ └── telegram.py # Telegram notifier +├── tests/ # Test suite +│ ├── conftest.py +│ ├── test_config.py +│ ├── test_dns_providers.py +│ └── test_ip_checkers.py +├── config/ # Configuration files +│ ├── ipwarn.conf # Default config +│ ├── ipwarn.conf.example # Example config +│ ├── ipwarn.service # Systemd service file +│ └── ipwarn-systemd-easy-install.sh # Installation script +├── Dockerfile # Docker image (Python-based) +├── .dockerignore # Docker ignore file +├── Makefile # Build automation +├── pyproject.toml # Python project metadata +├── requirements.txt # Python dependencies +├── README.md # Updated documentation +├── CHANGELOG.md # Updated changelog +└── .circleci/config.yml # Updated CI/CD +``` + +## Code Statistics + +- **Total Python Files**: 17 +- **Total Lines of Python Code**: ~1,260 +- **Dependencies**: requests>=2.31.0 +- **Python Version**: 3.12+ + +## Installation + +### pip install +```bash +pip install -e . +``` + +### Docker +```bash +docker build -t ipwarn:2.0.0 . +docker run --mount type=bind,source=ipwarn.conf,target=/etc/ipwarn/ipwarn.conf ipwarn:2.0.0 +``` + +### Systemd +```bash +bash config/ipwarn-systemd-easy-install.sh +``` + +## Testing + +```bash +make dev-install +make test +make lint +make format +``` + +## Key Implementation Details + +### Config Parser +- Parses shell-like `.conf` files +- Supports quoted values (single/double) +- Handles comments and empty lines +- Provides type-safe access methods (get, get_bool, get_int, get_list) + +### IP Checker +- Tries checkers in configured order +- Validates IPv4 addresses +- Automatic failover on errors +- Configurable timeout + +### DNS Providers +- Check if update is needed before making API calls +- Proper error handling and logging +- Support for both root domain (@) and subdomains + +### Main Application +- Continuous monitoring loop +- Tracks last known IP to avoid unnecessary updates +- Updates all enabled providers on IP change +- Sends notifications on successful updates +- Graceful shutdown handling + +## Extending ipwarn + +### Adding a New DNS Provider + +1. Create file: `ipwarn/dns_providers/your_provider.py` +2. Inherit from `BaseDNSProvider` +3. Implement required methods: + - `_validate_config()` + - `get_current_ip(domain, record_name, record_type)` + - `update_ip(domain, record_name, record_type, ip)` +4. Add config options to `config.py` +5. Instantiate in `__main__.py` when enabled + +### Adding a New Notifier + +1. Create file: `ipwarn/notifiers/your_notifier.py` +2. Inherit from `BaseNotifier` +3. Implement required methods: + - `_validate_config()` + - `send(message)` + +## Backward Compatibility + +- Config file format remains compatible with v1.x +- CLI flags maintained (`-h`, `-v`, `-c/--config`) +- Systemd service uses same binary path (`/usr/local/bin/ipwarn`) +- Docker images available on GitHub Container Registry: `ghcr.io/caldito/ipwarn:2.0.0` + +## Next Steps for Users + +1. **Existing v1.x users**: + - No changes needed to config file + - Update to v2.0.0 via Docker or pip + - Optional: Add Porkbun settings + +2. **New Porkbun users**: + - Add Porkbun config to `ipwarn.conf` + - Set `UPDATE_PORKBUN=true` + - Provide API credentials + +3. **IP checker failover**: + - Optional: Set `IP_CHECKERS` in config + - Default: tries all checkers in order + +## CI/CD Updates + +- GitHub Actions for CI/CD +- Uses Python 3.12 for testing +- Added pytest for testing +- Added linters (ruff, mypy) +- Multi-arch Docker builds (amd64, arm64) on GitHub Container Registry + +## Documentation + +- Updated README with new features +- Updated CHANGELOG with v2.0.0 changes +- Added development guide +- Included examples for adding new providers + +## License + +MIT License (unchanged from v1.x) + +## Status: ✅ Complete + +All planned features have been implemented and the codebase is ready for testing and deployment. diff --git a/Makefile b/Makefile index 4d50e0b..7b9e94b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,51 @@ -all: build-docker +VERSION ?= 2.0.0 +VENV ?= .venv +UV ?= uv -test: - ./ipwarn -h +.PHONY: help setup install dev-install test lint format build-docker build-docker-multi push-docker clean run run-once -build-docker: - docker build . -t pablogcaldito/ipwarn:$(VERSION) +help: ## Show this help message + @echo 'Usage: make [target]' + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +setup: ## Setup development environment with uv and venv + @command -v $(UV) >/dev/null 2>&1 || { echo "uv not found. Install it from https://github.com/astral-sh/uv"; exit 1; } + $(UV) venv $(VENV) + $(UV) pip install -e ".[dev]" + +install: ## Install the package locally + $(UV) pip install -e . + +dev-install: ## Setup development environment (alias for setup) + $(MAKE) setup + +test: ## Run tests + $(VENV)/bin/pytest tests/ -v + +lint: ## Run linters + $(VENV)/bin/ruff check ipwarn/ tests/ + $(VENV)/bin/mypy ipwarn/ + +format: ## Format code + $(VENV)/bin/black ipwarn/ tests/ + $(VENV)/bin/ruff check --fix ipwarn/ tests/ + +build-docker: ## Build Docker image for current architecture + docker build -t ipwarn:$(VERSION) . + +build-docker-multi: ## Build Docker image for multiple architectures + docker buildx build --platform linux/amd64,linux/arm64 -t ipwarn:$(VERSION) . + +push-docker: ## Push Docker image to registry + docker tag ipwarn:$(VERSION) ghcr.io/caldito/ipwarn:$(VERSION) && \ + docker push ghcr.io/caldito/ipwarn:$(VERSION) + +run: ## Run ipwarn with default config + $(VENV)/bin/python -m ipwarn --config config/ipwarn.conf + +run-once: ## Run ipwarn once (for testing) + $(VENV)/bin/python -m ipwarn --config config/ipwarn.conf --once + +clean: ## Clean build artifacts + rm -rf build/ dist/ *.egg-info __pycache__ ipwarn/__pycache__ ipwarn/**/*.pyc + find . -type d -name __pycache__ -exec rm -rf {} + || true diff --git a/README.md b/README.md index 08048d3..652442e 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,158 @@ # ipwarn -A simple Dynamic DNS Update Client written in Bash. +ipwarn is a simple Dynamic DNS Update Client. -The purpose of this project is to be able to update DNS records of the main hosting providers directly and also provide other functionalities like notifiying through Telegram. +## Features -For now it is able to update Godaddy's DNS records and notify using Telegram Bots. +- **Multiple DNS Providers**: Update records on **Porkbun** and **GoDaddy** +- **Automatic IP Detection**: Checks your public IP with automatic failover between multiple services +- **Telegram Notifications**: Get notified instantly when your IP changes +- **Easy Configuration**: Simple config file with sensible defaults +- **Production Ready**: Docker images ready for easy deployment +- **Test Once**: Run in dry-run mode to verify your setup before going live -## Prerequisites :clipboard: +## Quick Start -In order to work ipwarn needs `curl`. - -## Configuration :gear: -This software needs to use the APIs of different services. Due to this in the config file `/etc/ipwarn/ipwarn.conf` you can choose the services to enable and store the tokens for those. The configs inside this file are quite self descriptive, so I won't go into details here. - -With the flag `-c` or `--config` you can override the default location of this file. +### Docker -## Running :running: +```bash +# Build the image +docker build -t ipwarn:2.0.0 . -### Docker -``` -docker run --mount type=bind,source=your-custom-ipwarn.conf,target=/etc/ipwarn/ipwarn.conf pablogcaldito/ipwarn:v1.0.1 +# Run with your config file +docker run -d \ + --name ipwarn \ + --mount type=bind,source=$(pwd)/ipwarn.conf,target=/etc/ipwarn/ipwarn.conf \ + ipwarn:2.0.0 ``` -### Systemd install -Run the install script + +Or pull from GitHub Container Registry: +```bash +docker pull ghcr.io/caldito/ipwarn:2.0.0 +docker run -d \ + --name ipwarn \ + --mount type=bind,source=$(pwd)/ipwarn.conf,target=/etc/ipwarn/ipwarn.conf \ + ghcr.io/caldito/ipwarn:2.0.0 ``` -curl -O https://raw.githubusercontent.com/caldito/ipwarn/master/config/ipwarn-systemd-easy-install.sh && sudo bash ./ipwarn-systemd-easy-install.sh + +## Configuration + +Create or edit `/etc/ipwarn/ipwarn.conf` with your settings: + +```bash +# Check interval in seconds (default: 30) +INTERVAL=30 + +# IP checking services (comma-separated, in order) +IP_CHECKERS=icanhazip.com,ipify.org,ifconfig.me + +# Telegram Notifications +UPDATE_TELEGRAM=false +TEL_API_TOKEN=your_bot_token +TEL_CHAT_ID=your_chat_id + +# GoDaddy DNS +UPDATE_GODADDY=false +GD_DOMAIN=example.com +GD_RECORD_NAME="@" # Use "@" for root domain or "www" for subdomain +GD_RECORD_TYPE="A" +GD_API_KEY=your_api_key +GD_API_SECRET=your_api_secret + +# Porkbun DNS +UPDATE_PORKBUN=false +PB_DOMAIN=example.com +PB_RECORD_NAME="@" # Use "@" for root domain or "www" for subdomain +PB_RECORD_TYPE="A" +PB_API_KEY=your_api_key +PB_SECRET_API_KEY=your_secret_api_key +PB_TTL=600 # Time to live in seconds + +# Logging level (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO ``` -Override the config found in `/etc/ipwarn/ipwarn.conf` -Restart the service +**Tip**: Test your configuration first with `--dry-run` flag: +```bash +docker run --rm \ + --mount type=bind,source=ipwarn.conf,target=/etc/ipwarn/ipwarn.conf \ + ghcr.io/caldito/ipwarn:2.0.0 --dry-run --once ``` -sudo systemd restart ipwarn.service + +## CLI Options + +```bash +ipwarn [-h] [--version] [-c CONFIG] [--once] [--dry-run] + +Options: + -h, --help Show help message + -v, --version Show version + -c, --config CONFIG Path to config file (default: /etc/ipwarn/ipwarn.conf) + --once Run once and exit (for testing) + --dry-run Don't actually update DNS records (for testing) ``` -### Standalone run +## Getting API Keys + +### GoDaddy +1. Go to [developer.godaddy.com](https://developer.godaddy.com/) +2. Sign in and go to Keys & Credentials +3. Create a new API key +4. Select Production and copy your Key and Secret + +### Porkbun +1. Log in to [porkbun.com](https://porkbun.com/) +2. Go to Account → API Access +3. Create a new API key +4. Copy your API Key and Secret API Key + +### Telegram Bot +1. Open Telegram and start a chat with [@BotFather](https://t.me/BotFather) +2. Send `/newbot` and follow the instructions +3. Copy your bot token +4. To get your chat ID, send a message to your bot and visit: `https://api.telegram.org/bot/getUpdates` + +## Development -After clonning the repo go into its directory and run: -./ipwarn --config your-config-file +### Prerequisites +- Python 3.12+ +- [uv](https://github.com/astral-sh/uv) (Python package manager) +### Setup Development Environment -## Built with :hammer_and_wrench: +```bash +# Clone the repository +git clone https://github.com/caldito/ipwarn.git +cd ipwarn -* [Telegram Bot API](https://core.telegram.org/bots/apis) - Used to notify when IP changes -* [GoDaddy API](https://developer.godaddy.com/) - Used to update DNS records in GoDaddy +# Setup development environment with uv +make setup -## Contributing :handshake: +# Run tests +make test -Please read [CONTRIBUTING.md](https://github.com/caldito/ipwarn/blob/master/CONTRIBUTING.md) for details on the process for submitting pull requests to the project. +# Format code +make format -Also read [CODE_OF_CONDUCT.md](https://github.com/caldito/ipwarn/blob/master/CODE_OF_CONDUCT.md) for details on the code of conduct. +# Run linters +make lint -## Authors :black_nib: +# Run locally for testing +make run-once +``` + +### Development Commands + +```bash +make setup # Setup venv with uv +make test # Run tests +make lint # Run linters (ruff, mypy) +make format # Format code (black, ruff) +make run # Run continuously +make run-once # Run once for testing +make clean # Clean build artifacts +``` -See the [contributors](https://github.com/caldito/ipwarn/graphs/contributors) page. +## License -## License :balance_scale: -This project is licensed under the MIT License - see the [LICENSE](https://github.com/caldito/ipwarn/blob/master/LICENSE) file for details +MIT License - see the [LICENSE](https://github.com/caldito/ipwarn/blob/master/LICENSE) file for details diff --git a/config/ipwarn-systemd-easy-install.sh b/config/ipwarn-systemd-easy-install.sh deleted file mode 100644 index c72003a..0000000 --- a/config/ipwarn-systemd-easy-install.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -u - -if [[ $EUID > 0 ]] - then echo "Please run as root" - exit -fi - - -# Download files (program, config and service) -echo "Downloading files..." -mkdir -p /tmp/ipwarn-install -pushd /tmp/ipwarn-install -curl -O https://raw.githubusercontent.com/caldito/ipwarn/master/ipwarn -curl -O https://raw.githubusercontent.com/caldito/ipwarn/master/config/ipwarn.conf -curl -O https://raw.githubusercontent.com/caldito/ipwarn/master/config/ipwarn.service -echo "Files downloaded successfully..." - -# Create user -echo "Creating user..." -useradd --system ipwarn || true -echo "User created successfully" - -# Copy files and set permissions -echo "Copying files and setting permissions..." -cp ./ipwarn /usr/local/bin/ipwarn -chown ipwarn:ipwarn /usr/local/bin/ipwarn -chmod 755 /usr/local/bin/ipwarn - -mkdir -p /etc/ipwarn -chown ipwarn:ipwarn /etc/ipwarn -chmod 755 /etc/ipwarn - -cp ./ipwarn.conf /etc/ipwarn/ipwarn.conf -chown ipwarn:ipwarn /etc/ipwarn/ipwarn.conf -chmod 600 /etc/ipwarn/ipwarn.conf - -cp ./ipwarn.service /etc/systemd/system/ipwarn.service -chown root /etc/systemd/system/ipwarn.service -chmod 644 /etc/systemd/system/ipwarn.service -popd -echo "Files copied and permissions set successfully" - -# Prepare systemd service -echo "Preparing systemd service..." -systemctl daemon-reload -systemctl start ipwarn.service -printf "Systemd service prepared successfully\n\n" - -# Finish -printf "ipwarn installation completed successfully!\n\n" - -echo "The program is now running with the default config. You can edit the config in \"/etc/ipwarn/ipwarn.conf\" and then restart the ipwarn service runinng \"systemctl restart ipwarn\"" -echo "If you wish the service to run on startup run: systemctl enable ipwarn.service" diff --git a/config/ipwarn.conf b/config/ipwarn.conf index 74070c3..c830ee1 100644 --- a/config/ipwarn.conf +++ b/config/ipwarn.conf @@ -1,18 +1,32 @@ # ipwarn config file -# The purpose of this file is to store the API keys and other information in order to be able to use the services you need # Interval (in seconds) to check for ip changes INTERVAL=30 +# IP Checkers (comma-separated, in order of preference) +IP_CHECKERS=icanhazip.com,ipify.org,ifconfig.me + # Telegram -UPDATE_TELEGRAM=false # "true" or "false" +UPDATE_TELEGRAM=false TEL_API_TOKEN= TEL_API_ID= # GoDaddy -UPDATE_GODADDY=false # "true" or "false" +UPDATE_GODADDY=false GD_DOMAIN= GD_RECORD_NAME="@" GD_RECORD_TYPE="A" GD_API_KEY= GD_API_SECRET= + +# Porkbun +UPDATE_PORKBUN=false +PB_DOMAIN= +PB_RECORD_NAME="@" +PB_RECORD_TYPE="A" +PB_API_KEY= +PB_SECRET_API_KEY= +PB_TTL=600 + +# Logging +LOG_LEVEL=INFO diff --git a/config/ipwarn.conf.example b/config/ipwarn.conf.example new file mode 100644 index 0000000..c830ee1 --- /dev/null +++ b/config/ipwarn.conf.example @@ -0,0 +1,32 @@ +# ipwarn config file + +# Interval (in seconds) to check for ip changes +INTERVAL=30 + +# IP Checkers (comma-separated, in order of preference) +IP_CHECKERS=icanhazip.com,ipify.org,ifconfig.me + +# Telegram +UPDATE_TELEGRAM=false +TEL_API_TOKEN= +TEL_API_ID= + +# GoDaddy +UPDATE_GODADDY=false +GD_DOMAIN= +GD_RECORD_NAME="@" +GD_RECORD_TYPE="A" +GD_API_KEY= +GD_API_SECRET= + +# Porkbun +UPDATE_PORKBUN=false +PB_DOMAIN= +PB_RECORD_NAME="@" +PB_RECORD_TYPE="A" +PB_API_KEY= +PB_SECRET_API_KEY= +PB_TTL=600 + +# Logging +LOG_LEVEL=INFO diff --git a/config/ipwarn.service b/config/ipwarn.service deleted file mode 100644 index d1fd5e3..0000000 --- a/config/ipwarn.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=ipwarn dynamic dns update client -After=network.target - -[Service] -ExecStart=/usr/local/bin/ipwarn --config /etc/ipwarn/ipwarn.conf -Restart=always - -[Install] -WantedBy=multi-user.target diff --git a/ipwarn b/ipwarn deleted file mode 100755 index 7f8085d..0000000 --- a/ipwarn +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash - - -print_version(){ - echo "ipwarn v1.0.1" -} - -print_help(){ - echo "$(print_version)" - echo "usage: ipwarn ... " - echo "flags:" - echo " ipwarn {-h --help} display the help (this) and exit" - echo " ipwarn {-v -V --version} display the version and exit" - echo " ipwarn {-c --config } config-file choose a custom location for the config file" -} - -telegram(){ - result="$(curl -s -X POST "https://api.telegram.org/bot${TEL_API_TOKEN}/sendMessage" -d chat_id="${TEL_API_ID}" -d text="New ip is ${new_ip}")" - echo "Telegram warning sent" -} - -godaddy(){ - result="$(curl -s -X PUT "https://api.godaddy.com/v1/domains/${GD_DOMAIN}/records/${GD_RECORD_TYPE}/${GD_RECORD_NAME}" -H "Authorization: sso-key ${GD_API_KEY}:${GD_API_SECRET}" -H "Content-Type: application/json" -d "[{\"data\": \"${new_ip}\"}]")" - echo "GoDaddy record updated" -} - -get_config(){ - config_file="/etc/ipwarn/ipwarn.conf" - - while [ $# -gt 0 ]; do - case "$1" in - -c|--config) - shift - config_file=$1 - shift - ;; - *) - break - ;; - esac - done - - if [ -e "${config_file}" ]; then - if ! source "${config_file}"; then - echo "error reading or opening config file ${config_file}" - exit 1 - fi - else - echo "error reading or opening config file ${config_file}" - exit 1 - fi -} - -main(){ - # Check if help is wanted - if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then - print_help - exit 0 - fi - - # Check if the user wants the version - if [ "$1" = "-v" ] || [ "$1" = "-V" ] || [ "$1" = "--version" ]; then - print_version - exit 0 - fi - - get_config "$@" - - # Infinite loop that checks if the ip has changed - while true; do - new_ip="$(curl -s icanhazip.com)" - if [ "${new_ip}" != "${ip}" ]; then - ip="${new_ip}" - echo "New ip is ${new_ip}" - if [ "$UPDATE_TELEGRAM" = true ]; then - telegram - fi - if [ "$UPDATE_GODADDY" = true ]; then - godaddy - fi - fi - sleep "${INTERVAL}" - done -} - -main "$@" diff --git a/ipwarn/__init__.py b/ipwarn/__init__.py new file mode 100644 index 0000000..478db01 --- /dev/null +++ b/ipwarn/__init__.py @@ -0,0 +1,3 @@ +"""ipwarn - Dynamic DNS update client with modular provider support.""" + +__version__ = "2.0.0" diff --git a/ipwarn/__main__.py b/ipwarn/__main__.py new file mode 100644 index 0000000..ee6c5e8 --- /dev/null +++ b/ipwarn/__main__.py @@ -0,0 +1,232 @@ +"""Main entry point for ipwarn.""" + +import argparse +import logging +import sys +import time + +from ipwarn import __version__ +from ipwarn.config import Config, ConfigError +from ipwarn.dns_providers.base import BaseDNSProvider, DNSProviderError +from ipwarn.dns_providers.godaddy import GoDaddyProvider +from ipwarn.dns_providers.porkbun import PorkbunProvider +from ipwarn.ip_checkers import IPChecker, IPCheckerError +from ipwarn.logger import setup_logging +from ipwarn.notifiers.base import BaseNotifier, NotifierError +from ipwarn.notifiers.telegram import TelegramNotifier + +logger = logging.getLogger(__name__) + + +class IPWarn: + """Main ipwarn application.""" + + def __init__( + self, + config: Config, + dry_run: bool = False, + ): + """Initialize ipwarn application. + + Args: + config: Configuration instance. + dry_run: If True, don't actually update DNS records. + """ + self.config = config + self.dry_run = dry_run + self.ip_checker = IPChecker(checkers=config.ip_checkers) + self.providers: list[tuple[str, BaseDNSProvider]] = [] + self.notifiers: list[tuple[str, BaseNotifier]] = [] + self._last_ip: str | None = None + + self._setup_providers() + self._setup_notifiers() + + def _setup_providers(self) -> None: + """Set up DNS providers based on configuration.""" + if self.config.update_godaddy: + godaddy_provider = GoDaddyProvider( + api_key=self.config.godaddy_api_key, + api_secret=self.config.godaddy_api_secret, + ) + self.providers.append(("GoDaddy", godaddy_provider)) + logger.info("GoDaddy provider enabled") + + if self.config.update_porkbun: + porkbun_provider = PorkbunProvider( + api_key=self.config.porkbun_api_key, + secret_api_key=self.config.porkbun_secret_api_key, + ttl=self.config.porkbun_ttl, + ) + self.providers.append(("Porkbun", porkbun_provider)) + logger.info("Porkbun provider enabled") + + if not self.providers: + logger.warning("No DNS providers enabled") + + def _setup_notifiers(self) -> None: + """Set up notification handlers based on configuration.""" + if self.config.update_telegram: + notifier = TelegramNotifier( + api_token=self.config.telegram_api_token, + api_id=self.config.telegram_api_id, + ) + self.notifiers.append(("Telegram", notifier)) + logger.info("Telegram notifier enabled") + + def _send_notification(self, message: str) -> None: + """Send notification to all enabled notifiers. + + Args: + message: Message to send. + """ + for name, notifier in self.notifiers: + try: + notifier.send(message) + logger.debug(f"Notification sent via {name}") + except NotifierError as e: + logger.error(f"Failed to send notification via {name}: {e}") + + def _update_dns_providers(self, new_ip: str) -> None: + """Update DNS records for all enabled providers. + + Args: + new_ip: New IP address to update to. + """ + for name, provider in self.providers: + try: + domain = ( + self.config.godaddy_domain if name == "GoDaddy" else self.config.porkbun_domain + ) + record_name = ( + self.config.godaddy_record_name + if name == "GoDaddy" + else self.config.porkbun_record_name + ) + record_type = ( + self.config.godaddy_record_type + if name == "GoDaddy" + else self.config.porkbun_record_type + ) + + if provider.needs_update(domain, record_name, record_type, new_ip): + if self.dry_run: + logger.info( + f"[DRY RUN] Would update {name} record: {record_name}.{domain} -> {new_ip}" + ) + else: + provider.update_ip(domain, record_name, record_type, new_ip) + logger.info(f"Updated {name} record: {record_name}.{domain} -> {new_ip}") + else: + logger.info(f"{name} record already correct: {new_ip}") + + except DNSProviderError as e: + logger.error(f"Failed to update {name} DNS record: {e}") + + def check_and_update(self) -> str | None: + """Check IP and update DNS if changed. + + Returns: + New IP address if changed, None otherwise. + """ + try: + new_ip = self.ip_checker.get_ip() + logger.info(f"Current IP: {new_ip}") + + if not hasattr(self, "_last_ip") or self._last_ip != new_ip: + logger.info(f"IP changed from {getattr(self, '_last_ip', 'unknown')} to {new_ip}") + + self._update_dns_providers(new_ip) + self._send_notification(f"New IP address: {new_ip}") + + self._last_ip = new_ip + return new_ip + else: + logger.debug("IP unchanged") + return None + + except IPCheckerError as e: + logger.error(f"Failed to get IP address: {e}") + return None + + def run_once(self) -> None: + """Run a single check and update.""" + logger.info("Running once") + self.check_and_update() + + def run_forever(self) -> None: + """Run continuous monitoring loop.""" + logger.info(f"Starting ipwarn v{__version__}") + logger.info(f"Checking IP every {self.config.interval} seconds") + + try: + while True: + self.check_and_update() + time.sleep(self.config.interval) + + except KeyboardInterrupt: + logger.info("Shutting down...") + sys.exit(0) + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments. + + Returns: + Parsed arguments. + """ + parser = argparse.ArgumentParser( + description="Dynamic DNS update client with modular provider support" + ) + parser.add_argument( + "-v", + "--version", + action="version", + version=f"ipwarn v{__version__}", + ) + parser.add_argument( + "-c", + "--config", + default="/etc/ipwarn/ipwarn.conf", + help="Path to configuration file", + ) + parser.add_argument( + "--once", + action="store_true", + help="Run once and exit (useful for testing)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Don't actually update DNS records", + ) + + return parser.parse_args() + + +def main() -> None: + """Main entry point.""" + args = parse_args() + + try: + config = Config(args.config) + + setup_logging(config.log_level) + + app = IPWarn(config, dry_run=args.dry_run) + + if args.once: + app.run_once() + else: + app.run_forever() + + except ConfigError as e: + print(f"Configuration error: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + logger.exception(f"Unexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ipwarn/config.py b/ipwarn/config.py new file mode 100644 index 0000000..c71b54d --- /dev/null +++ b/ipwarn/config.py @@ -0,0 +1,203 @@ +"""Configuration file parser for ipwarn. + +This module provides backward-compatible parsing of shell-like configuration files, +as used by the original Bash version of ipwarn. +""" + +import re +from pathlib import Path + + +class ConfigError(Exception): + """Configuration error exception.""" + + +class Config: + """Configuration parser for shell-like config files.""" + + def __init__(self, config_path: str = "/etc/ipwarn/ipwarn.conf"): + """Initialize configuration. + + Args: + config_path: Path to the configuration file. + """ + self.config_path = Path(config_path) + self._values: dict[str, str] = {} + self._parse() + + def _parse(self) -> None: + """Parse the configuration file.""" + if not self.config_path.exists(): + raise ConfigError(f"Config file not found: {self.config_path}") + + with open(self.config_path, encoding="utf-8") as f: + for _line_num, line in enumerate(f, 1): + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith("#"): + continue + + # Parse KEY=VALUE format + match = re.match(r"^([A-Z_]+)=(.*)$", line) + if not match: + continue + + key, value = match.groups() + + # Remove quotes if present (both single and double) + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + + self._values[key] = value + + def get(self, key: str, default: str = "") -> str: + """Get a configuration value. + + Args: + key: Configuration key. + default: Default value if key not found. + + Returns: + The configuration value or default. + """ + return self._values.get(key, default) + + def get_bool(self, key: str, default: bool = False) -> bool: + """Get a boolean configuration value. + + Args: + key: Configuration key. + default: Default value if key not found. + + Returns: + True if value is "true" (case-insensitive), False otherwise. + """ + value = self.get(key, str(default)).lower() + return value == "true" + + def get_int(self, key: str, default: int = 0) -> int: + """Get an integer configuration value. + + Args: + key: Configuration key. + default: Default value if key not found or invalid. + + Returns: + The integer value or default. + """ + value = self.get(key, str(default)) + try: + return int(value) + except ValueError: + return default + + def get_list(self, key: str, default: str = "") -> list[str]: + """Get a comma-separated list from configuration. + + Args: + key: Configuration key. + default: Default value if key not found. + + Returns: + List of values, split by comma. + """ + value = self.get(key, default) + return [item.strip() for item in value.split(",") if item.strip()] + + @property + def interval(self) -> int: + """Get check interval in seconds.""" + return self.get_int("INTERVAL", 30) + + @property + def ip_checkers(self) -> list[str]: + """Get list of IP checker services.""" + return self.get_list("IP_CHECKERS", "icanhazip.com,ipify.org,ifconfig.me") + + @property + def log_level(self) -> str: + """Get logging level.""" + return self.get("LOG_LEVEL", "INFO").upper() + + @property + def update_telegram(self) -> bool: + """Check if Telegram updates are enabled.""" + return self.get_bool("UPDATE_TELEGRAM", False) + + @property + def telegram_api_token(self) -> str: + """Get Telegram API token.""" + return self.get("TEL_API_TOKEN", "") + + @property + def telegram_api_id(self) -> str: + """Get Telegram API ID.""" + return self.get("TEL_API_ID", "") + + @property + def update_godaddy(self) -> bool: + """Check if GoDaddy updates are enabled.""" + return self.get_bool("UPDATE_GODADDY", False) + + @property + def godaddy_domain(self) -> str: + """Get GoDaddy domain.""" + return self.get("GD_DOMAIN", "") + + @property + def godaddy_record_name(self) -> str: + """Get GoDaddy record name.""" + return self.get("GD_RECORD_NAME", "@") + + @property + def godaddy_record_type(self) -> str: + """Get GoDaddy record type.""" + return self.get("GD_RECORD_TYPE", "A") + + @property + def godaddy_api_key(self) -> str: + """Get GoDaddy API key.""" + return self.get("GD_API_KEY", "") + + @property + def godaddy_api_secret(self) -> str: + """Get GoDaddy API secret.""" + return self.get("GD_API_SECRET", "") + + @property + def update_porkbun(self) -> bool: + """Check if Porkbun updates are enabled.""" + return self.get_bool("UPDATE_PORKBUN", False) + + @property + def porkbun_domain(self) -> str: + """Get Porkbun domain.""" + return self.get("PB_DOMAIN", "") + + @property + def porkbun_record_name(self) -> str: + """Get Porkbun record name.""" + return self.get("PB_RECORD_NAME", "@") + + @property + def porkbun_record_type(self) -> str: + """Get Porkbun record type.""" + return self.get("PB_RECORD_TYPE", "A") + + @property + def porkbun_api_key(self) -> str: + """Get Porkbun API key.""" + return self.get("PB_API_KEY", "") + + @property + def porkbun_secret_api_key(self) -> str: + """Get Porkbun secret API key.""" + return self.get("PB_SECRET_API_KEY", "") + + @property + def porkbun_ttl(self) -> int: + """Get Porkbun TTL.""" + return self.get_int("PB_TTL", 600) diff --git a/ipwarn/dns_providers/__init__.py b/ipwarn/dns_providers/__init__.py new file mode 100644 index 0000000..b0a696e --- /dev/null +++ b/ipwarn/dns_providers/__init__.py @@ -0,0 +1 @@ +"""DNS provider module for ipwarn.""" diff --git a/ipwarn/dns_providers/base.py b/ipwarn/dns_providers/base.py new file mode 100644 index 0000000..fe3b518 --- /dev/null +++ b/ipwarn/dns_providers/base.py @@ -0,0 +1,90 @@ +"""Base class for DNS providers.""" + +import logging +from abc import ABC, abstractmethod +from typing import Any + +logger = logging.getLogger(__name__) + + +class DNSProviderError(Exception): + """DNS provider error exception.""" + + +class BaseDNSProvider(ABC): + """Abstract base class for DNS providers.""" + + def __init__(self, config: dict[str, Any]): + """Initialize DNS provider. + + Args: + config: Provider-specific configuration. + """ + self.config = config + self._validate_config() + + @abstractmethod + def _validate_config(self) -> None: + """Validate provider-specific configuration. + + Raises: + DNSProviderError: If configuration is invalid. + """ + pass + + @abstractmethod + def get_current_ip(self, domain: str, record_name: str, record_type: str) -> str: + """Get current IP address for a DNS record. + + Args: + domain: Domain name. + record_name: Record name (e.g., '@' for root, 'www' for subdomain). + record_type: Record type (e.g., 'A', 'AAAA'). + + Returns: + Current IP address. + + Raises: + DNSProviderError: If unable to retrieve current IP. + """ + pass + + @abstractmethod + def update_ip(self, domain: str, record_name: str, record_type: str, ip: str) -> bool: + """Update IP address for a DNS record. + + Args: + domain: Domain name. + record_name: Record name (e.g., '@' for root, 'www' for subdomain). + record_type: Record type (e.g., 'A', 'AAAA'). + ip: New IP address. + + Returns: + True if update was successful, False otherwise. + + Raises: + DNSProviderError: If update fails. + """ + pass + + def needs_update(self, domain: str, record_name: str, record_type: str, new_ip: str) -> bool: + """Check if DNS record needs to be updated. + + Args: + domain: Domain name. + record_name: Record name. + record_type: Record type. + new_ip: New IP address to check. + + Returns: + True if update is needed, False if IP is already correct. + + Raises: + DNSProviderError: If unable to check current IP. + """ + try: + current_ip = self.get_current_ip(domain, record_name, record_type) + return current_ip != new_ip + except DNSProviderError as e: + logger.warning(f"Failed to check current IP for {domain}: {e}") + return True diff --git a/ipwarn/dns_providers/godaddy.py b/ipwarn/dns_providers/godaddy.py new file mode 100644 index 0000000..c97f251 --- /dev/null +++ b/ipwarn/dns_providers/godaddy.py @@ -0,0 +1,109 @@ +"""GoDaddy DNS provider implementation.""" + +import logging + +import requests + +from ipwarn.dns_providers.base import BaseDNSProvider, DNSProviderError + +logger = logging.getLogger(__name__) + + +class GoDaddyProvider(BaseDNSProvider): + """GoDaddy DNS provider.""" + + BASE_URL = "https://api.godaddy.com/v1" + + def __init__(self, api_key: str, api_secret: str): + """Initialize GoDaddy provider. + + Args: + api_key: GoDaddy API key. + api_secret: GoDaddy API secret. + """ + config = { + "api_key": api_key, + "api_secret": api_secret, + } + super().__init__(config) + + def _validate_config(self) -> None: + """Validate GoDaddy configuration. + + Raises: + DNSProviderError: If configuration is invalid. + """ + if not self.config.get("api_key"): + raise DNSProviderError("GoDaddy API key is required") + if not self.config.get("api_secret"): + raise DNSProviderError("GoDaddy API secret is required") + + def _get_headers(self) -> dict[str, str]: + """Get request headers with authentication. + + Returns: + Dictionary of headers. + """ + return { + "Authorization": f"sso-key {self.config['api_key']}:{self.config['api_secret']}", + "Content-Type": "application/json", + } + + def get_current_ip(self, domain: str, record_name: str, record_type: str) -> str: + """Get current IP address for a DNS record. + + Args: + domain: Domain name. + record_name: Record name (e.g., '@' for root, 'www' for subdomain). + record_type: Record type (e.g., 'A', 'AAAA'). + + Returns: + Current IP address. + + Raises: + DNSProviderError: If unable to retrieve current IP. + """ + url = f"{self.BASE_URL}/domains/{domain}/records/{record_type}/{record_name}" + + try: + response = requests.get(url, headers=self._get_headers(), timeout=10) + response.raise_for_status() + + records: list[dict[str, str | int]] = response.json() + + if not records: + raise DNSProviderError(f"No {record_type} record found for {record_name}.{domain}") + + return str(records[0]["data"]) + + except requests.exceptions.RequestException as e: + raise DNSProviderError(f"Failed to get current IP from GoDaddy: {e}") from e + + def update_ip(self, domain: str, record_name: str, record_type: str, ip: str) -> bool: + """Update IP address for a DNS record. + + Args: + domain: Domain name. + record_name: Record name (e.g., '@' for root, 'www' for subdomain). + record_type: Record type (e.g., 'A', 'AAAA'). + ip: New IP address. + + Returns: + True if update was successful, False otherwise. + + Raises: + DNSProviderError: If update fails. + """ + url = f"{self.BASE_URL}/domains/{domain}/records/{record_type}/{record_name}" + + data = [{"data": ip}] + + try: + response = requests.put(url, headers=self._get_headers(), json=data, timeout=10) + response.raise_for_status() + + logger.info(f"GoDaddy record updated: {record_name}.{domain} -> {ip}") + return True + + except requests.exceptions.RequestException as e: + raise DNSProviderError(f"Failed to update IP on GoDaddy: {e}") from e diff --git a/ipwarn/dns_providers/porkbun.py b/ipwarn/dns_providers/porkbun.py new file mode 100644 index 0000000..1e3da5d --- /dev/null +++ b/ipwarn/dns_providers/porkbun.py @@ -0,0 +1,133 @@ +"""Porkbun DNS provider implementation.""" + +import logging + +import requests + +from ipwarn.dns_providers.base import BaseDNSProvider, DNSProviderError + +logger = logging.getLogger(__name__) + + +class PorkbunProvider(BaseDNSProvider): + """Porkbun DNS provider.""" + + BASE_URL = "https://api.porkbun.com/api/json/v3" + + def __init__(self, api_key: str, secret_api_key: str, ttl: int = 600): + """Initialize Porkbun provider. + + Args: + api_key: Porkbun API key. + secret_api_key: Porkbun secret API key. + ttl: Time to live for DNS records (default: 600). + """ + config = { + "api_key": api_key, + "secret_api_key": secret_api_key, + "ttl": ttl, + } + super().__init__(config) + + def _validate_config(self) -> None: + """Validate Porkbun configuration. + + Raises: + DNSProviderError: If configuration is invalid. + """ + if not self.config.get("api_key"): + raise DNSProviderError("Porkbun API key is required") + if not self.config.get("secret_api_key"): + raise DNSProviderError("Porkbun secret API key is required") + + def _get_auth_payload(self) -> dict[str, str]: + """Get authentication payload for requests. + + Returns: + Dictionary with authentication credentials. + """ + return { + "apikey": self.config["api_key"], + "secretapikey": self.config["secret_api_key"], + } + + def get_current_ip(self, domain: str, record_name: str, record_type: str) -> str: + """Get current IP address for a DNS record. + + Args: + domain: Domain name. + record_name: Record name (e.g., '@' for root, 'www' for subdomain). + record_type: Record type (e.g., 'A', 'AAAA'). + + Returns: + Current IP address. + + Raises: + DNSProviderError: If unable to retrieve current IP. + """ + # Build URL for retrieveByNameType endpoint + # Format: /dns/retrieveByNameType/DOMAIN/TYPE/[SUBDOMAIN] + subdomain_path = f"/{record_name}" if record_name != "@" else "" + url = f"{self.BASE_URL}/dns/retrieveByNameType/{domain}/{record_type}{subdomain_path}" + + try: + response = requests.post(url, json=self._get_auth_payload(), timeout=10) + response.raise_for_status() + + result = response.json() + + if result.get("status") != "SUCCESS": + raise DNSProviderError(f"Porkbun API error: {result}") + + records: list[dict[str, str | int]] = result.get("records", []) + + if not records: + raise DNSProviderError(f"No {record_type} record found for {record_name}.{domain}") + + # Return IP from the first record + content = records[0].get("content", "") + if not content: + raise DNSProviderError(f"No content in record for {record_name}.{domain}") + return str(content) + + except requests.exceptions.RequestException as e: + raise DNSProviderError(f"Failed to get current IP from Porkbun: {e}") from e + + def update_ip(self, domain: str, record_name: str, record_type: str, ip: str) -> bool: + """Update IP address for a DNS record. + + Args: + domain: Domain name. + record_name: Record name (e.g., '@' for root, 'www' for subdomain). + record_type: Record type (e.g., 'A', 'AAAA'). + ip: New IP address. + + Returns: + True if update was successful, False otherwise. + + Raises: + DNSProviderError: If update fails. + """ + # Build URL for editByNameType endpoint + # Format: /dns/editByNameType/DOMAIN/TYPE/[SUBDOMAIN] + subdomain_path = f"/{record_name}" if record_name != "@" else "" + url = f"{self.BASE_URL}/dns/editByNameType/{domain}/{record_type}{subdomain_path}" + + payload = self._get_auth_payload() + payload["content"] = ip + payload["ttl"] = self.config["ttl"] + + try: + response = requests.post(url, json=payload, timeout=10) + response.raise_for_status() + + result = response.json() + + if result.get("status") != "SUCCESS": + raise DNSProviderError(f"Porkbun API error: {result}") + + logger.info(f"Porkbun record updated: {record_name}.{domain} -> {ip}") + return True + + except requests.exceptions.RequestException as e: + raise DNSProviderError(f"Failed to update IP on Porkbun: {e}") from e diff --git a/ipwarn/ip_checkers.py b/ipwarn/ip_checkers.py new file mode 100644 index 0000000..c1c9029 --- /dev/null +++ b/ipwarn/ip_checkers.py @@ -0,0 +1,103 @@ +"""IP checker module with multiple services and failover support.""" + +import logging +import re + +import requests + +logger = logging.getLogger(__name__) + + +class IPCheckerError(Exception): + """IP checker error exception.""" + + +class IPChecker: + """IP checker with multiple services and failover.""" + + CHECKERS = { + "icanhazip.com": "https://icanhazip.com", + "ipify.org": "https://api.ipify.org", + "ifconfig.me": "https://ifconfig.me/ip", + } + + def __init__(self, checkers: list[str] | None = None, timeout: int = 5): + """Initialize IP checker. + + Args: + checkers: List of checker names (order matters for failover). + timeout: Timeout in seconds for each checker. + """ + self.checkers = checkers or list(self.CHECKERS.keys()) + self.timeout = timeout + + def get_ip(self) -> str: + """Get current IP address with failover. + + Returns: + The current IP address. + + Raises: + IPCheckerError: If all checkers fail. + """ + last_error = None + + for checker in self.checkers: + url = self.CHECKERS.get(checker) + if not url: + logger.warning(f"Unknown IP checker: {checker}") + continue + + try: + ip = self._check_ip(url) + logger.debug(f"Got IP {ip} from {checker}") + return ip + except IPCheckerError as e: + logger.warning(f"Failed to get IP from {checker}: {e}") + last_error = e + continue + + raise IPCheckerError(f"All IP checkers failed. Last error: {last_error}") + + def _check_ip(self, url: str) -> str: + """Check IP from a single service. + + Args: + url: URL to check IP from. + + Returns: + The IP address. + + Raises: + IPCheckerError: If the check fails. + """ + try: + response = requests.get(url, timeout=self.timeout) + response.raise_for_status() + + ip = response.text.strip() + + # Validate IP address format + if not self._is_valid_ip(ip): + raise IPCheckerError(f"Invalid IP address: {ip}") + + return ip + + except requests.exceptions.Timeout: + raise IPCheckerError(f"Timeout checking IP from {url}") from None + except requests.exceptions.RequestException as e: + raise IPCheckerError(f"Request failed: {e}") from e + + @staticmethod + def _is_valid_ip(ip: str) -> bool: + """Validate IP address format (IPv4 only for now). + + Args: + ip: IP address string. + + Returns: + True if valid IPv4 address, False otherwise. + """ + # Simple IPv4 validation + ipv4_pattern = r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" + return bool(re.match(ipv4_pattern, ip)) diff --git a/ipwarn/logger.py b/ipwarn/logger.py new file mode 100644 index 0000000..148e2be --- /dev/null +++ b/ipwarn/logger.py @@ -0,0 +1,39 @@ +"""Logging configuration for ipwarn.""" + +import logging +import sys + + +def setup_logging(level: str = "INFO") -> logging.Logger: + """Configure logging for ipwarn. + + Args: + level: Logging level (DEBUG, INFO, WARNING, ERROR). + + Returns: + Configured logger instance. + """ + log_level = getattr(logging, level.upper(), logging.INFO) + + # Create logger + logger = logging.getLogger("ipwarn") + logger.setLevel(log_level) + + # Remove existing handlers + logger.handlers.clear() + + # Create console handler + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(log_level) + + # Create formatter + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z", + ) + handler.setFormatter(formatter) + + # Add handler to logger + logger.addHandler(handler) + + return logger diff --git a/ipwarn/notifiers/__init__.py b/ipwarn/notifiers/__init__.py new file mode 100644 index 0000000..19fba7a --- /dev/null +++ b/ipwarn/notifiers/__init__.py @@ -0,0 +1 @@ +"""Notifier module for ipwarn.""" diff --git a/ipwarn/notifiers/base.py b/ipwarn/notifiers/base.py new file mode 100644 index 0000000..263aeda --- /dev/null +++ b/ipwarn/notifiers/base.py @@ -0,0 +1,48 @@ +"""Base class for notifiers.""" + +import logging +from abc import ABC, abstractmethod +from typing import Any + +logger = logging.getLogger(__name__) + + +class NotifierError(Exception): + """Notifier error exception.""" + + +class BaseNotifier(ABC): + """Abstract base class for notifiers.""" + + def __init__(self, config: dict[str, Any]): + """Initialize notifier. + + Args: + config: Notifier-specific configuration. + """ + self.config = config + self._validate_config() + + @abstractmethod + def _validate_config(self) -> None: + """Validate notifier-specific configuration. + + Raises: + NotifierError: If configuration is invalid. + """ + pass + + @abstractmethod + def send(self, message: str) -> bool: + """Send a notification message. + + Args: + message: Message to send. + + Returns: + True if notification was sent successfully, False otherwise. + + Raises: + NotifierError: If notification fails. + """ + pass diff --git a/ipwarn/notifiers/telegram.py b/ipwarn/notifiers/telegram.py new file mode 100644 index 0000000..3232fc6 --- /dev/null +++ b/ipwarn/notifiers/telegram.py @@ -0,0 +1,73 @@ +"""Telegram notifier implementation.""" + +import logging + +import requests + +from ipwarn.notifiers.base import BaseNotifier, NotifierError + +logger = logging.getLogger(__name__) + + +class TelegramNotifier(BaseNotifier): + """Telegram bot notifier.""" + + BASE_URL = "https://api.telegram.org" + + def __init__(self, api_token: str, api_id: str): + """Initialize Telegram notifier. + + Args: + api_token: Telegram bot API token. + api_id: Telegram chat ID to send notifications to. + """ + config = { + "api_token": api_token, + "api_id": api_id, + } + super().__init__(config) + + def _validate_config(self) -> None: + """Validate Telegram configuration. + + Raises: + NotifierError: If configuration is invalid. + """ + if not self.config.get("api_token"): + raise NotifierError("Telegram API token is required") + if not self.config.get("api_id"): + raise NotifierError("Telegram API ID is required") + + def send(self, message: str) -> bool: + """Send a notification message. + + Args: + message: Message to send. + + Returns: + True if notification was sent successfully, False otherwise. + + Raises: + NotifierError: If notification fails. + """ + url = f"{self.BASE_URL}/bot{self.config['api_token']}/sendMessage" + + payload = { + "chat_id": self.config["api_id"], + "text": message, + } + + try: + response = requests.post(url, json=payload, timeout=10) + response.raise_for_status() + + result = response.json() + + if not result.get("ok"): + raise NotifierError(f"Telegram API error: {result}") + + logger.debug("Telegram notification sent successfully") + return True + + except requests.exceptions.RequestException as e: + raise NotifierError(f"Failed to send Telegram notification: {e}") from e diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f71de4d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ipwarn" +version = "2.0.0" +description = "Dynamic DNS update client with modular provider support" +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" +authors = [ + {name = "Pablo Gómez-Caldito Gómez"} +] +dependencies = [ + "requests>=2.31.0" +] + +[project.scripts] +ipwarn = "ipwarn.__main__:main" + +[tool.setuptools] +packages = ["ipwarn", "ipwarn.dns_providers", "ipwarn.notifiers"] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "black>=23.0.0", + "ruff>=0.1.0", + "mypy>=1.5.0", + "types-requests>=2.31.0" +] + +[tool.black] +line-length = 100 +target-version = ['py312'] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..15dd0f3 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test module for ipwarn.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dc0dbd8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Pytest configuration and fixtures.""" + +import pytest + + +@pytest.fixture +def sample_config(): + """Sample configuration for testing.""" + return { + "INTERVAL": "30", + "IP_CHECKERS": "icanhazip.com,ipify.org,ifconfig.me", + "UPDATE_TELEGRAM": "true", + "TEL_API_TOKEN": "test_token", + "TEL_API_ID": "test_id", + "UPDATE_GODADDY": "true", + "GD_DOMAIN": "example.com", + "GD_RECORD_NAME": "@", + "GD_RECORD_TYPE": "A", + "GD_API_KEY": "test_key", + "GD_API_SECRET": "test_secret", + "UPDATE_PORKBUN": "true", + "PB_DOMAIN": "example.com", + "PB_RECORD_NAME": "@", + "PB_RECORD_TYPE": "A", + "PB_API_KEY": "test_key", + "PB_SECRET_API_KEY": "test_secret", + "PB_TTL": "600", + "LOG_LEVEL": "INFO", + } diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..071362f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,94 @@ +"""Tests for configuration parser.""" + +import tempfile + +import pytest + +from ipwarn.config import Config, ConfigError + + +def test_config_parse(sample_config): + """Test basic configuration parsing.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".conf") as f: + for key, value in sample_config.items(): + f.write(f"{key}={value}\n") + f.flush() + + config = Config(f.name) + assert config.interval == 30 + assert config.ip_checkers == ["icanhazip.com", "ipify.org", "ifconfig.me"] + assert config.update_telegram is True + assert config.update_godaddy is True + assert config.update_porkbun is True + assert config.log_level == "INFO" + + +def test_config_bool_values(): + """Test boolean configuration values.""" + config_text = """ +UPDATE_TELEGRAM=true +UPDATE_GODADDY=false +UPDATE_PORKBUN=true +""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".conf") as f: + f.write(config_text) + f.flush() + + config = Config(f.name) + assert config.update_telegram is True + assert config.update_godaddy is False + assert config.update_porkbun is True + + +def test_config_with_comments_and_empty_lines(): + """Test parsing config with comments and empty lines.""" + config_text = """ +# This is a comment +INTERVAL=60 + +# Another comment +UPDATE_TELEGRAM=false +""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".conf") as f: + f.write(config_text) + f.flush() + + config = Config(f.name) + assert config.interval == 60 + assert config.update_telegram is False + + +def test_config_quoted_values(): + """Test parsing quoted values.""" + config_text = """ +GD_RECORD_TYPE="A" +GD_DOMAIN='example.com' +""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".conf") as f: + f.write(config_text) + f.flush() + + config = Config(f.name) + assert config.godaddy_record_type == "A" + assert config.godaddy_domain == "example.com" + + +def test_config_file_not_found(): + """Test error when config file doesn't exist.""" + with pytest.raises(ConfigError, match="Config file not found"): + Config("/nonexistent/path/ipwarn.conf") + + +def test_config_get_with_default(): + """Test getting values with defaults.""" + config_text = """ +INTERVAL=30 +""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".conf") as f: + f.write(config_text) + f.flush() + + config = Config(f.name) + assert config.get("NONEXISTENT_KEY", "default") == "default" + assert config.get_bool("NONEXISTENT_BOOL", True) is True + assert config.get_int("NONEXISTENT_INT", 42) == 42 diff --git a/tests/test_dns_providers.py b/tests/test_dns_providers.py new file mode 100644 index 0000000..a0f9809 --- /dev/null +++ b/tests/test_dns_providers.py @@ -0,0 +1,63 @@ +"""Tests for DNS providers.""" + +import pytest + +from ipwarn.dns_providers.base import BaseDNSProvider, DNSProviderError +from ipwarn.dns_providers.godaddy import GoDaddyProvider +from ipwarn.dns_providers.porkbun import PorkbunProvider + + +def test_godaddy_provider_validation(): + """Test GoDaddy provider configuration validation.""" + with pytest.raises(DNSProviderError, match="API key is required"): + GoDaddyProvider(api_key="", api_secret="secret") + + with pytest.raises(DNSProviderError, match="API secret is required"): + GoDaddyProvider(api_key="key", api_secret="") + + +def test_godaddy_provider_valid(): + """Test GoDaddy provider with valid configuration.""" + provider = GoDaddyProvider(api_key="test_key", api_secret="test_secret") + assert provider.config["api_key"] == "test_key" + assert provider.config["api_secret"] == "test_secret" + + +def test_porkbun_provider_validation(): + """Test Porkbun provider configuration validation.""" + with pytest.raises(DNSProviderError, match="API key is required"): + PorkbunProvider(api_key="", secret_api_key="secret") + + with pytest.raises(DNSProviderError, match="secret API key is required"): + PorkbunProvider(api_key="key", secret_api_key="") + + +def test_porkbun_provider_valid(): + """Test Porkbun provider with valid configuration.""" + provider = PorkbunProvider(api_key="test_key", secret_api_key="test_secret") + assert provider.config["api_key"] == "test_key" + assert provider.config["secret_api_key"] == "test_secret" + assert provider.config["ttl"] == 600 + + +def test_porkbun_provider_custom_ttl(): + """Test Porkbun provider with custom TTL.""" + provider = PorkbunProvider(api_key="test_key", secret_api_key="test_secret", ttl=300) + assert provider.config["ttl"] == 300 + + +def test_base_provider_needs_update(): + """Test base provider needs_update method.""" + class MockProvider(BaseDNSProvider): + def _validate_config(self): + pass + + def get_current_ip(self, domain, record_name, record_type): + return "192.168.1.1" + + def update_ip(self, domain, record_name, record_type, ip): + return True + + provider = MockProvider({}) + assert provider.needs_update("example.com", "@", "A", "192.168.1.2") is True + assert provider.needs_update("example.com", "@", "A", "192.168.1.1") is False diff --git a/tests/test_ip_checkers.py b/tests/test_ip_checkers.py new file mode 100644 index 0000000..8c0668e --- /dev/null +++ b/tests/test_ip_checkers.py @@ -0,0 +1,25 @@ +"""Tests for IP checkers.""" + +from ipwarn.ip_checkers import IPChecker + + +def test_ip_checker_initialization(): + """Test IP checker initialization.""" + checker = IPChecker() + assert checker.checkers == ["icanhazip.com", "ipify.org", "ifconfig.me"] + assert checker.timeout == 5 + + custom_checker = IPChecker(checkers=["icanhazip.com"], timeout=10) + assert custom_checker.checkers == ["icanhazip.com"] + assert custom_checker.timeout == 10 + + +def test_ip_checker_invalid_ip(): + """Test IP validation.""" + assert IPChecker._is_valid_ip("192.168.1.1") is True + assert IPChecker._is_valid_ip("0.0.0.0") is True + assert IPChecker._is_valid_ip("255.255.255.255") is True + assert IPChecker._is_valid_ip("invalid") is False + assert IPChecker._is_valid_ip("192.168.1") is False + assert IPChecker._is_valid_ip("192.168.1.256") is False + assert IPChecker._is_valid_ip("") is False