From 0183f7561468611e04db6caf88407ae5bcfe8977 Mon Sep 17 00:00:00 2001 From: boredchilada <82670806+boredchilada@users.noreply.github.com> Date: Sat, 11 Jan 2025 16:26:31 -0500 Subject: [PATCH] actual initial commit --- NamesiloDDNS.py | 322 +++++++++++++++++++++++++++++++++++++++++++ README.md | 189 +++++++++++++++++++++++++ example_domains.json | 4 + requirements.txt | 3 + 4 files changed, 518 insertions(+) create mode 100644 NamesiloDDNS.py create mode 100644 README.md create mode 100644 example_domains.json create mode 100644 requirements.txt diff --git a/NamesiloDDNS.py b/NamesiloDDNS.py new file mode 100644 index 0000000..b41a9d3 --- /dev/null +++ b/NamesiloDDNS.py @@ -0,0 +1,322 @@ +import requests +import xml.etree.ElementTree as ET +import argparse +import sys +import json +import logging +import time +import os +import getpass +from pathlib import Path +from typing import Optional, Dict, List, Union, Set +from urllib.parse import urlparse +import ipaddress +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry + +# Configuration +CONFIG = { + 'API_BASE_URL': 'https://www.namesilo.com/api', + 'IP_CHECK_URL': 'https://ifconfig.me', + 'DEFAULT_TTL': 3603, + 'REQUEST_TIMEOUT': 30, + 'MAX_RETRIES': 3, + 'BACKOFF_FACTOR': 0.3, + 'SPF_TEMPLATE': 'v=spf1 a mx a:{domain} ip4:***CHANGEME*** ip4:{ip} ?all' +} + +def get_api_key() -> str: + """ + Securely prompt for the NameSilo API key. + The API key is only stored in memory during runtime. + """ + print("\nNameSilo API Key Required") + print("------------------------") + print("Please enter your NameSilo API key.") + print("The key will not be stored and you'll need to enter it each time you run the script.") + print("You can find your API key in your NameSilo account settings.") + + api_key = getpass.getpass("API Key: ").strip() + + if not api_key: + print("Error: API key is required") + sys.exit(1) + + return api_key + +class NameSiloDDNS: + def __init__(self, api_key: str, domains_file: Optional[str], record_types: Set[str], update_spf: bool, log_level: str = 'INFO'): + """ + Initialize the NameSilo DDNS updater. + + Args: + api_key: NameSilo API key + domains_file: Path to the JSON file containing domains configuration, or None to fetch all domains + record_types: Set of record types to update ('A' and/or 'AAAA') + update_spf: Whether to update SPF records + log_level: Logging level (default: INFO) + """ + self.api_key = api_key + self.domains_file = domains_file + self.record_types = record_types + self.update_spf = update_spf + self.setup_logging(log_level) + self.session = self.setup_requests_session() + + def setup_logging(self, log_level: str) -> None: + """Configure logging with both file and console handlers.""" + logging.basicConfig( + level=getattr(logging, log_level), + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('namesilo_ddns.log'), + logging.StreamHandler() + ] + ) + self.logger = logging.getLogger(__name__) + + def setup_requests_session(self) -> requests.Session: + """Configure requests session with retry mechanism.""" + session = requests.Session() + retry_strategy = Retry( + total=CONFIG['MAX_RETRIES'], + backoff_factor=CONFIG['BACKOFF_FACTOR'], + status_forcelist=[429, 500, 502, 503, 504] + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + session.mount("http://", adapter) + session.mount("https://", adapter) + return session + + def validate_domain(self, domain: str) -> bool: + """Validate domain name format.""" + try: + result = urlparse("//" + domain) + return all([result.netloc, "." in result.netloc]) + except Exception: + return False + + def validate_ip(self, ip: str) -> bool: + """Validate IP address format.""" + try: + ipaddress.ip_address(ip) + return True + except ValueError: + return False + + def get_wan_ip(self) -> Optional[str]: + """Fetch current WAN IP address.""" + try: + response = self.session.get(CONFIG['IP_CHECK_URL'], timeout=CONFIG['REQUEST_TIMEOUT']) + response.raise_for_status() + ip = response.text.strip() + if self.validate_ip(ip): + return ip + self.logger.error(f"Invalid IP address received: {ip}") + return None + except requests.exceptions.RequestException as e: + self.logger.error(f"Failed to fetch WAN IP: {str(e)}") + return None + + def get_all_domains(self) -> Optional[List[str]]: + """Fetch all domains from NameSilo account.""" + url = f"{CONFIG['API_BASE_URL']}/listDomains" + params = { + 'version': '1', + 'type': 'xml', + 'key': self.api_key + } + + try: + response = self.session.get(url, params=params, timeout=CONFIG['REQUEST_TIMEOUT']) + response.raise_for_status() + + # Log the raw response for debugging + self.logger.debug(f"Raw API response: {response.text}") + + root = ET.fromstring(response.text) + + # Check if the API request was successful (code 300 means success) + code = root.find('.//code') + detail = root.find('.//detail') + + if code is not None and code.text == '300' and detail is not None and detail.text == 'success': + domains = [] + domains_elem = root.find('.//domains') + if domains_elem is not None: + for domain in domains_elem.findall('domain'): + if domain.text: + domains.append(domain.text) + + if domains: + self.logger.info(f"Found {len(domains)} domains in your account: {', '.join(domains)}") + return domains + else: + self.logger.error("No domains found in the account") + return None + else: + error_code = code.text if code is not None else "unknown" + error_detail = detail.text if detail is not None else "unknown error" + self.logger.error(f"API request failed with code {error_code}: {error_detail}") + return None + + except requests.exceptions.RequestException as e: + self.logger.error(f"Error retrieving domain list: {str(e)}") + return None + except ET.ParseError as e: + self.logger.error(f"Error parsing domain list response: {str(e)}") + return None + + def get_domain_records(self, domain: str) -> Optional[str]: + """Fetch DNS records for a domain.""" + if not self.validate_domain(domain): + self.logger.error(f"Invalid domain name: {domain}") + return None + + url = f"{CONFIG['API_BASE_URL']}/dnsListRecords" + params = { + 'version': '1', + 'type': 'xml', + 'key': self.api_key, + 'domain': domain + } + + try: + response = self.session.get(url, params=params, timeout=CONFIG['REQUEST_TIMEOUT']) + response.raise_for_status() + return response.text + except requests.exceptions.RequestException as e: + self.logger.error(f"Error retrieving domain records for {domain}: {str(e)}") + return None + + def update_dns_record(self, domain: str, record_id: str, rrhost: str, rrvalue: str, rrttl: int = CONFIG['DEFAULT_TTL']) -> Optional[str]: + """Update a DNS record.""" + rrhost = '' if rrhost == '@' else rrhost + url = f"{CONFIG['API_BASE_URL']}/dnsUpdateRecord" + params = { + 'version': '1', + 'type': 'xml', + 'key': self.api_key, + 'domain': domain, + 'rrid': record_id, + 'rrhost': rrhost, + 'rrvalue': rrvalue, + 'rrttl': rrttl + } + + try: + response = self.session.get(url, params=params, timeout=CONFIG['REQUEST_TIMEOUT']) + response.raise_for_status() + return response.text + except requests.exceptions.RequestException as e: + self.logger.error(f"Error updating DNS record: {str(e)}") + return None + + def update_spf_record(self, domain: str, record_id: str, current_ip: str) -> Optional[str]: + """Update SPF record with current IP.""" + spf_record = CONFIG['SPF_TEMPLATE'].format(domain=domain, ip=current_ip) + return self.update_dns_record(domain, record_id, '@', spf_record) + + def process_domain_records(self, domain: str, subdomains: List[str], xml_data: Optional[str], current_ip: str) -> None: + """Process and update domain records as needed.""" + if xml_data is None: + return + + try: + root = ET.fromstring(xml_data) + records = root.findall(".//resource_record") + spf_updated = False + + for record in records: + record_type = record.find('type').text + record_id = record.find('record_id').text + value = record.find('value').text + ttl = record.find('ttl').text + host = record.find('host').text + + if self.update_spf and record_type == 'TXT' and host == domain and 'v=spf1' in value: + self.logger.info(f"Updating SPF record for {domain}") + response = self.update_spf_record(domain, record_id, current_ip) + self.logger.info(f"SPF update response: {response}") + spf_updated = True + elif record_type in self.record_types: + if '*' in subdomains or any(host == domain or host.endswith('.' + domain) for sd in subdomains): + if value != current_ip or ttl != str(CONFIG['DEFAULT_TTL']): + self.logger.info(f"Updating {record_type} record for {host} to {current_ip}") + rrhost = '@' if host == domain else host.replace('.' + domain, '') + response = self.update_dns_record(domain, record_id, rrhost, current_ip) + self.logger.info(f"DNS update response: {response}") + else: + self.logger.info(f"No update needed for {host}") + + if self.update_spf and not spf_updated: + self.logger.warning(f"SPF record not found for {domain}. You may need to create it manually.") + + except ET.ParseError as e: + self.logger.error(f"Error parsing XML response for {domain}: {str(e)}") + + def load_domains(self) -> Dict[str, List[str]]: + """Load domains configuration from JSON file or fetch all domains.""" + if self.domains_file: + try: + with open(self.domains_file, 'r') as f: + return json.load(f) + except FileNotFoundError: + self.logger.error(f"Domains file '{self.domains_file}' not found.") + sys.exit(1) + except json.JSONDecodeError: + self.logger.error(f"Invalid JSON in domains file '{self.domains_file}'.") + sys.exit(1) + else: + domains = self.get_all_domains() + if domains: + # Create a dictionary with all domains using wildcard for subdomains + return {domain: ["*"] for domain in domains} + else: + self.logger.error("Failed to fetch domains from NameSilo.") + sys.exit(1) + + def run(self, check_ip_only: bool = False) -> None: + """Main execution method.""" + current_wan_ip = self.get_wan_ip() + if not current_wan_ip: + self.logger.error("Failed to fetch current WAN IP.") + return + + self.logger.info(f"Current WAN IP: {current_wan_ip}") + if check_ip_only: + return + + domains = self.load_domains() + for domain, subdomains in domains.items(): + self.logger.info(f"Processing domain: {domain}") + xml_records = self.get_domain_records(domain) + self.process_domain_records(domain, subdomains, xml_records, current_ip=current_wan_ip) + +def main(): + """Entry point of the script.""" + parser = argparse.ArgumentParser(description="NameSilo Dynamic DNS Updater") + parser.add_argument("-d", "--domains-file", help="JSON file containing domains and subdomains (optional, if not provided will update all domains)") + parser.add_argument("-c", "--check-ip-only", action="store_true", help="Only check and display the current WAN IP") + parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Set the logging level") + parser.add_argument("--record-types", default="A,AAAA", help="Comma-separated list of record types to update (A,AAAA)") + parser.add_argument("--no-spf", action="store_true", help="Skip updating SPF records") + + args = parser.parse_args() + + # Get API key securely through user input + api_key = get_api_key() + + # Parse record types + record_types = set(args.record_types.upper().split(',')) + valid_types = {'A', 'AAAA'} + if not record_types.issubset(valid_types): + print(f"Error: Invalid record types. Valid types are: {', '.join(valid_types)}") + sys.exit(1) + + updater = NameSiloDDNS(api_key, args.domains_file, record_types, not args.no_spf, args.log_level) + updater.run(args.check_ip_only) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e627e6 --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# NameSilo Dynamic DNS Updater + +This Python script is designed to update DNS records for domains hosted on NameSilo, a popular domain registrar and DNS management platform. It allows you to dynamically update the IP addresses associated with your domains and subdomains whenever your WAN IP changes. + +## Features + +- Fetches the current WAN IP address using the `ifconfig.me` service +- Retrieves and updates DNS records for specified domains from NameSilo's API +- Can automatically update all domains in your NameSilo account +- Selectively update A and/or AAAA records +- Optional SPF record updates +- Support for wildcard subdomain updates +- Robust error handling with retry mechanism +- Comprehensive logging system +- Input validation for domains and IP addresses +- Configurable through command-line arguments +- JSON-based domain configuration (optional) +- Secure API key handling through interactive prompt + +## Prerequisites + +Before using this script, make sure you have the following: + +- Python 3.x installed on your system +- A NameSilo account with API access enabled +- Your NameSilo API key (you'll be prompted to enter it when running the script) + +## Installation + +1. Clone the repository: +```bash +git clone https://github.com/boredchilada/NamesiloDDNS.git +cd NamesiloDDNS +``` + +2. Create and activate a virtual environment: + +### Windows +```cmd +python -m venv venv +venv\Scripts\activate +``` + +### Linux/macOS +```bash +python3 -m venv venv +source venv/bin/activate +``` + +3. Install dependencies: +```bash +pip install -r requirements.txt +``` + +## API Key Security + +For enhanced security, the script prompts for your NameSilo API key each time it runs. This ensures that: +- Your API key is never stored in plaintext +- The key only exists in memory during script execution +- There's no risk of accidental exposure through environment variables or config files +- You maintain full control over when and how your API key is used + +## Usage + +### Basic Usage (Update All Domains) + +To update all domains in your NameSilo account: +```bash +python NamesiloDDNS.py +``` + +This will: +- Prompt you for your NameSilo API key +- Fetch all domains from your NameSilo account +- Update all subdomains for each domain +- Update both A and AAAA records by default + +### Advanced Usage (Specific Domains) + +If you want to update only specific domains, create a JSON file (e.g., `domains.json`) with your domain configuration: + +```json +{ + "example.com": ["*"], + "example2.com": ["www", "mail", "dev"] +} +``` + +Then run: +```bash +python NamesiloDDNS.py -d domains.json +``` + +### Command-line Arguments + +- `-d, --domains-file`: JSON file containing domains and subdomains (optional) +- `-c, --check-ip-only`: Only check and display the current WAN IP +- `--log-level`: Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) +- `--record-types`: Comma-separated list of record types to update (default: "A,AAAA") +- `--no-spf`: Skip updating SPF records + +### Examples + +1. Update A records for all domains in your account: +```bash +python NamesiloDDNS.py --record-types A +``` + +2. Update A records for all domains, skip SPF: +```bash +python NamesiloDDNS.py --record-types A --no-spf +``` + +3. Update specific domains only: +```bash +python NamesiloDDNS.py -d domains.json --record-types A +``` + +4. Check current WAN IP: +```bash +python NamesiloDDNS.py -c +``` + +5. Run with debug logging: +```bash +python NamesiloDDNS.py --log-level DEBUG +``` + +## Logging + +The script maintains a log file (`namesilo_ddns.log`) containing detailed information about: +- DNS record updates +- API responses +- Errors and warnings +- IP address changes + +## Important Note About Automation + +Due to security considerations, automated/unattended operation (e.g., via cron jobs or Task Scheduler) is currently not supported. This is a deliberate design choice to prevent the storage of API keys in plaintext and reduce the risk of unauthorized access. The script requires manual input of the API key each time it runs. + +If you need automated updates, consider using NameSilo's built-in DDNS service or implementing your own secure key management system. + +## Error Handling + +The script includes robust error handling for: +- Network connectivity issues +- API rate limiting +- Invalid domain names +- Invalid IP addresses +- Configuration file errors +- API authentication failures + +## Troubleshooting + +1. Check the log file (`namesilo_ddns.log`) for detailed error messages +2. Verify your API key is valid +3. Ensure your domains.json file is properly formatted (if using one) +4. Check your internet connection +5. Verify the domains in your configuration are active in your NameSilo account + +Common issues: +- "Invalid API Key": Verify your NameSilo API key +- "Invalid Domain": Check domain format in domains.json +- "Connection Error": Check internet connectivity +- "Rate Limit": Wait before retrying (automatic retry implemented) +- "Invalid Record Type": Ensure --record-types contains only A and/or AAAA + +## Security Considerations + +- API key is never stored in plaintext +- The key only exists in memory during script execution +- Input is masked when entering the API key +- The script validates all inputs before processing +- HTTPS is used for all API communications +- Request timeouts prevent hanging operations + +## License + +This script is released under the [MIT License](https://opensource.org/licenses/MIT). + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Disclaimer + +This script is provided as-is without any warranty. Use it at your own risk. Make sure to comply with NameSilo's API usage terms and conditions. + +--- diff --git a/example_domains.json b/example_domains.json new file mode 100644 index 0000000..d30739f --- /dev/null +++ b/example_domains.json @@ -0,0 +1,4 @@ +{ + "example.com": ["*"], + "example2.com": ["sub1","sub2"] +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..82de278 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests==2.32.2 +urllib3==2.2.2 +packaging==24.2