Merakit-Deploy/gitea/gitea_deployer/dns_manager.py

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