287 lines
8.1 KiB
Python
287 lines
8.1 KiB
Python
"""
|
|
DNS management module with Cloudflare API integration
|
|
|
|
Direct Python API calls replacing cloudflare-add.sh and cloudflare-remove.sh
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Dict, Optional
|
|
|
|
import requests
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DNSError(Exception):
|
|
"""Raised when DNS operations fail"""
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class DNSRecord:
|
|
"""Represents a DNS record"""
|
|
record_id: str
|
|
hostname: str
|
|
ip: str
|
|
record_type: str
|
|
|
|
|
|
class DNSManager:
|
|
"""Python wrapper for Cloudflare DNS operations"""
|
|
|
|
def __init__(self, api_token: str, zone_id: str):
|
|
"""
|
|
Initialize DNS manager
|
|
|
|
Args:
|
|
api_token: Cloudflare API token
|
|
zone_id: Cloudflare zone ID
|
|
"""
|
|
self._api_token = api_token
|
|
self._zone_id = zone_id
|
|
self._base_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"
|
|
self._headers = {
|
|
"Authorization": f"Bearer {api_token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
self._logger = logging.getLogger(f"{__name__}.DNSManager")
|
|
|
|
def check_record_exists(self, hostname: str) -> bool:
|
|
"""
|
|
Check if DNS record exists using Cloudflare API
|
|
|
|
Args:
|
|
hostname: Fully qualified domain name
|
|
|
|
Returns:
|
|
True if record exists, False otherwise
|
|
|
|
Raises:
|
|
DNSError: If API call fails
|
|
"""
|
|
self._logger.debug(f"Checking if DNS record exists: {hostname}")
|
|
|
|
try:
|
|
params = {"name": hostname}
|
|
response = requests.get(
|
|
self._base_url,
|
|
headers=self._headers,
|
|
params=params,
|
|
timeout=30
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
if not data.get("success", False):
|
|
errors = data.get("errors", [])
|
|
raise DNSError(f"Cloudflare API error: {errors}")
|
|
|
|
records = data.get("result", [])
|
|
exists = len(records) > 0
|
|
|
|
if exists:
|
|
self._logger.debug(f"DNS record exists: {hostname}")
|
|
else:
|
|
self._logger.debug(f"DNS record does not exist: {hostname}")
|
|
|
|
return exists
|
|
|
|
except requests.RequestException as e:
|
|
raise DNSError(f"Failed to check DNS record existence: {e}") from e
|
|
|
|
def add_record(
|
|
self,
|
|
hostname: str,
|
|
ip: str,
|
|
dry_run: bool = False
|
|
) -> DNSRecord:
|
|
"""
|
|
Add DNS A record
|
|
|
|
Args:
|
|
hostname: Fully qualified domain name
|
|
ip: IP address for A record
|
|
dry_run: If True, only log what would be done
|
|
|
|
Returns:
|
|
DNSRecord with record_id for rollback
|
|
|
|
Raises:
|
|
DNSError: If API call fails
|
|
"""
|
|
if dry_run:
|
|
self._logger.info(
|
|
f"[DRY-RUN] Would add DNS record: {hostname} -> {ip}"
|
|
)
|
|
return DNSRecord(
|
|
record_id="dry-run-id",
|
|
hostname=hostname,
|
|
ip=ip,
|
|
record_type="A"
|
|
)
|
|
|
|
self._logger.info(f"Adding DNS record: {hostname} -> {ip}")
|
|
|
|
try:
|
|
payload = {
|
|
"type": "A",
|
|
"name": hostname,
|
|
"content": ip,
|
|
"ttl": 1, # Automatic TTL
|
|
"proxied": False # DNS only, not proxied through Cloudflare
|
|
}
|
|
|
|
response = requests.post(
|
|
self._base_url,
|
|
headers=self._headers,
|
|
json=payload,
|
|
timeout=30
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
if not data.get("success", False):
|
|
errors = data.get("errors", [])
|
|
raise DNSError(f"Cloudflare API error: {errors}")
|
|
|
|
result = data.get("result", {})
|
|
record_id = result.get("id")
|
|
|
|
if not record_id:
|
|
raise DNSError("No record ID returned from Cloudflare API")
|
|
|
|
self._logger.info(f"DNS record added successfully: {record_id}")
|
|
|
|
return DNSRecord(
|
|
record_id=record_id,
|
|
hostname=hostname,
|
|
ip=ip,
|
|
record_type="A"
|
|
)
|
|
|
|
except requests.RequestException as e:
|
|
raise DNSError(f"Failed to add DNS record: {e}") from e
|
|
|
|
def remove_record(self, hostname: str, dry_run: bool = False) -> None:
|
|
"""
|
|
Remove DNS record by hostname
|
|
|
|
Args:
|
|
hostname: Fully qualified domain name
|
|
dry_run: If True, only log what would be done
|
|
|
|
Raises:
|
|
DNSError: If API call fails
|
|
"""
|
|
if dry_run:
|
|
self._logger.info(f"[DRY-RUN] Would remove DNS record: {hostname}")
|
|
return
|
|
|
|
self._logger.info(f"Removing DNS record: {hostname}")
|
|
|
|
try:
|
|
# First, get the record ID
|
|
params = {"name": hostname}
|
|
response = requests.get(
|
|
self._base_url,
|
|
headers=self._headers,
|
|
params=params,
|
|
timeout=30
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
if not data.get("success", False):
|
|
errors = data.get("errors", [])
|
|
raise DNSError(f"Cloudflare API error: {errors}")
|
|
|
|
records = data.get("result", [])
|
|
|
|
if not records:
|
|
self._logger.warning(f"No DNS record found for: {hostname}")
|
|
return
|
|
|
|
# Remove all matching records (typically just one)
|
|
for record in records:
|
|
record_id = record.get("id")
|
|
if record_id:
|
|
self.remove_record_by_id(record_id, dry_run=False)
|
|
|
|
except requests.RequestException as e:
|
|
raise DNSError(f"Failed to remove DNS record: {e}") from e
|
|
|
|
def remove_record_by_id(self, record_id: str, dry_run: bool = False) -> None:
|
|
"""
|
|
Remove DNS record by ID (more reliable for rollback)
|
|
|
|
Args:
|
|
record_id: Cloudflare DNS record ID
|
|
dry_run: If True, only log what would be done
|
|
|
|
Raises:
|
|
DNSError: If API call fails
|
|
"""
|
|
if dry_run:
|
|
self._logger.info(
|
|
f"[DRY-RUN] Would remove DNS record by ID: {record_id}"
|
|
)
|
|
return
|
|
|
|
self._logger.info(f"Removing DNS record by ID: {record_id}")
|
|
|
|
try:
|
|
url = f"{self._base_url}/{record_id}"
|
|
response = requests.delete(
|
|
url,
|
|
headers=self._headers,
|
|
timeout=30
|
|
)
|
|
|
|
# Handle 404/405 gracefully - record doesn't exist or can't be deleted
|
|
if response.status_code in [404, 405]:
|
|
self._logger.warning(
|
|
f"DNS record {record_id} not found or cannot be deleted (may already be removed)"
|
|
)
|
|
return
|
|
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
if not data.get("success", False):
|
|
errors = data.get("errors", [])
|
|
raise DNSError(f"Cloudflare API error: {errors}")
|
|
|
|
self._logger.info(f"DNS record removed successfully: {record_id}")
|
|
|
|
except requests.RequestException as e:
|
|
raise DNSError(f"Failed to remove DNS record: {e}") from e
|
|
|
|
def get_public_ip(self) -> str:
|
|
"""
|
|
Get public IP address from external service
|
|
|
|
Returns:
|
|
Public IP address as string
|
|
|
|
Raises:
|
|
DNSError: If IP retrieval fails
|
|
"""
|
|
self._logger.debug("Retrieving public IP address")
|
|
|
|
try:
|
|
response = requests.get("https://ipv4.icanhazip.com", timeout=10)
|
|
response.raise_for_status()
|
|
ip = response.text.strip()
|
|
|
|
self._logger.debug(f"Public IP: {ip}")
|
|
return ip
|
|
|
|
except requests.RequestException as e:
|
|
raise DNSError(f"Failed to retrieve public IP: {e}") from e
|