mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 08:36:55 +09:00
483 lines
18 KiB
Python
483 lines
18 KiB
Python
"""Provider-agnostic record clients for Route53, DigitalOcean, and GoDaddy.
|
|
|
|
Each client exposes the same record surface as :class:`CloudflareClient`:
|
|
``list_records``, ``find_record_id``, ``upsert``, and ``delete``. The provider
|
|
connection service and restore-point replay can therefore use the ownership
|
|
door without knowing provider wire formats.
|
|
"""
|
|
import logging
|
|
from typing import Optional
|
|
|
|
import requests
|
|
|
|
from app.services.dns.base import DnsCredential, DnsRecordSpec
|
|
from app.services.dns.cloudflare import parse_caa_value
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _fqdn(value: str) -> str:
|
|
return (value or '').strip().lower().rstrip('.')
|
|
|
|
|
|
def _relative_name(name: str, zone: str) -> str:
|
|
name = _fqdn(name)
|
|
zone = _fqdn(zone)
|
|
if not zone or name == zone:
|
|
return '@'
|
|
if name.endswith('.' + zone):
|
|
return name[:-len(zone) - 1]
|
|
return name or '@'
|
|
|
|
|
|
def _absolute_name(name: str, zone: str) -> str:
|
|
name = (name or '@').strip().rstrip('.')
|
|
zone = _fqdn(zone)
|
|
if name in ('', '@'):
|
|
return zone
|
|
if _fqdn(name) == zone or _fqdn(name).endswith('.' + zone):
|
|
return _fqdn(name)
|
|
return f'{name}.{zone}'.lower()
|
|
|
|
|
|
def _split_priority(spec: DnsRecordSpec):
|
|
content = spec.content
|
|
priority = spec.priority
|
|
if spec.record_type in ('MX', 'SRV') and priority is None:
|
|
head, _, rest = (content or '').strip().partition(' ')
|
|
if head.isdigit() and rest.strip():
|
|
priority, content = int(head), rest.strip()
|
|
return content, priority
|
|
|
|
|
|
def _http_error(response, data, fallback='Provider request failed'):
|
|
if isinstance(data, dict):
|
|
return data.get('message') or data.get('error') or fallback
|
|
return f'HTTP {getattr(response, "status_code", "?")}: {fallback}'
|
|
|
|
|
|
class Route53Client:
|
|
"""Normalized Route53 RRset client."""
|
|
|
|
def __init__(self, credential: DnsCredential, client=None):
|
|
self.cred = credential
|
|
self._boto_client = client
|
|
|
|
def _client(self):
|
|
if self._boto_client is None:
|
|
try:
|
|
import boto3
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
'boto3 is required for Route53 integration. '
|
|
'Install with: pip install boto3'
|
|
) from exc
|
|
self._boto_client = boto3.client(
|
|
'route53',
|
|
aws_access_key_id=self.cred.token,
|
|
aws_secret_access_key=self.cred.secret,
|
|
)
|
|
return self._boto_client
|
|
|
|
@staticmethod
|
|
def _record_id(record_type: str, name: str) -> str:
|
|
# Route53 has no per-record id. Its mutation identity is the RRset key.
|
|
return f'{record_type.upper()}:{_fqdn(name)}'
|
|
|
|
@staticmethod
|
|
def _content(record_type, value):
|
|
value = str(value or '')
|
|
if record_type == 'TXT' and len(value) >= 2 and value[0] == value[-1] == '"':
|
|
return value[1:-1]
|
|
return value.rstrip('.') if record_type in ('CNAME', 'NS', 'PTR') else value
|
|
|
|
def list_records(self, zone_id: str) -> dict:
|
|
try:
|
|
out = []
|
|
kwargs = {'HostedZoneId': zone_id}
|
|
while True:
|
|
response = self._client().list_resource_record_sets(**kwargs)
|
|
for rrset in response.get('ResourceRecordSets', []):
|
|
record_type = rrset.get('Type')
|
|
name = _fqdn(rrset.get('Name'))
|
|
ttl = rrset.get('TTL')
|
|
values = rrset.get('ResourceRecords') or []
|
|
for value in values:
|
|
content = self._content(record_type, value.get('Value'))
|
|
priority = None
|
|
if record_type in ('MX', 'SRV'):
|
|
head, _, rest = content.partition(' ')
|
|
if head.isdigit() and rest:
|
|
priority, content = int(head), rest
|
|
out.append({
|
|
'id': self._record_id(record_type, name),
|
|
'type': record_type,
|
|
'name': name,
|
|
'content': content,
|
|
'ttl': ttl,
|
|
'priority': priority,
|
|
'proxied': False,
|
|
})
|
|
if not response.get('IsTruncated'):
|
|
break
|
|
kwargs.update({
|
|
'StartRecordName': response['NextRecordName'],
|
|
'StartRecordType': response['NextRecordType'],
|
|
})
|
|
if response.get('NextRecordIdentifier'):
|
|
kwargs['StartRecordIdentifier'] = response['NextRecordIdentifier']
|
|
return {'success': True, 'records': out}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
def _find_rrset(self, zone_id, record_type, name):
|
|
fqdn = f'{_fqdn(name)}.'
|
|
response = self._client().list_resource_record_sets(
|
|
HostedZoneId=zone_id,
|
|
StartRecordName=fqdn,
|
|
StartRecordType=record_type,
|
|
MaxItems='1',
|
|
)
|
|
records = response.get('ResourceRecordSets', [])
|
|
return next((
|
|
item for item in records
|
|
if _fqdn(item.get('Name')) == _fqdn(name)
|
|
and item.get('Type') == record_type
|
|
), None)
|
|
|
|
def find_record_id(self, zone_id: str, record_type: str, name: str,
|
|
caa: dict = None):
|
|
del caa # Route53 identity is the whole name+type RRset.
|
|
rrset = self._find_rrset(zone_id, record_type, name)
|
|
if rrset is None:
|
|
return None
|
|
return self._record_id(record_type, name)
|
|
|
|
@staticmethod
|
|
def _rrset(spec: DnsRecordSpec):
|
|
content, priority = _split_priority(spec)
|
|
if spec.record_type == 'TXT':
|
|
content = f'"{content}"'
|
|
elif spec.record_type == 'CAA':
|
|
caa = parse_caa_value(content)
|
|
content = f'{caa["flags"]} {caa["tag"]} "{caa["value"]}"'
|
|
elif priority is not None:
|
|
content = f'{priority} {content}'
|
|
return {
|
|
'Name': f'{_fqdn(spec.name)}.',
|
|
'Type': spec.record_type,
|
|
'TTL': spec.ttl,
|
|
'ResourceRecords': [{'Value': content}],
|
|
}
|
|
|
|
def upsert(self, zone_id: str, spec: DnsRecordSpec,
|
|
record_id: str = None) -> dict:
|
|
try:
|
|
self._client().change_resource_record_sets(
|
|
HostedZoneId=zone_id,
|
|
ChangeBatch={'Changes': [{
|
|
'Action': 'UPSERT',
|
|
'ResourceRecordSet': self._rrset(spec),
|
|
}]},
|
|
)
|
|
return {
|
|
'success': True,
|
|
'record_id': self._record_id(spec.record_type, spec.name),
|
|
'message': f'{spec.record_type} record set for {spec.name}',
|
|
}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
def delete(self, zone_id: str, *, record_id: str = None,
|
|
record_type: str = None, name: str = None) -> dict:
|
|
try:
|
|
rrset = self._find_rrset(zone_id, record_type, name)
|
|
if rrset is None:
|
|
return {'success': True, 'message': 'Record not found (already deleted)'}
|
|
self._client().change_resource_record_sets(
|
|
HostedZoneId=zone_id,
|
|
ChangeBatch={'Changes': [{
|
|
'Action': 'DELETE',
|
|
'ResourceRecordSet': rrset,
|
|
}]},
|
|
)
|
|
return {'success': True, 'message': f'{record_type} record deleted for {name}'}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
|
|
class DigitalOceanClient:
|
|
"""Normalized DigitalOcean domain-record client."""
|
|
|
|
API_BASE = 'https://api.digitalocean.com/v2'
|
|
|
|
def __init__(self, credential: DnsCredential):
|
|
self.cred = credential
|
|
|
|
def _headers(self):
|
|
return {
|
|
'Authorization': f'Bearer {self.cred.token or ""}',
|
|
'Content-Type': 'application/json',
|
|
}
|
|
|
|
def _raw_records(self, zone_id, record_type=None):
|
|
params = '?per_page=200'
|
|
if record_type:
|
|
params += f'&type={record_type}'
|
|
response = requests.get(
|
|
f'{self.API_BASE}/domains/{zone_id}/records{params}',
|
|
headers=self._headers(), timeout=15,
|
|
)
|
|
data = response.json()
|
|
if response.status_code != 200:
|
|
raise RuntimeError(_http_error(response, data))
|
|
return data.get('domain_records', [])
|
|
|
|
@staticmethod
|
|
def _normalize(zone_id, record):
|
|
content = record.get('data', '')
|
|
priority = record.get('priority')
|
|
if record.get('type') == 'CAA':
|
|
content = f'{record.get("flags", 0)} {record.get("tag", "issue")} "{content}"'
|
|
return {
|
|
'id': str(record.get('id')),
|
|
'type': record.get('type'),
|
|
'name': _absolute_name(record.get('name'), zone_id),
|
|
'content': content,
|
|
'ttl': record.get('ttl'),
|
|
'priority': priority,
|
|
'proxied': False,
|
|
}
|
|
|
|
def list_records(self, zone_id: str) -> dict:
|
|
try:
|
|
return {
|
|
'success': True,
|
|
'records': [self._normalize(zone_id, row) for row in self._raw_records(zone_id)],
|
|
}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
def find_record_id(self, zone_id: str, record_type: str, name: str,
|
|
caa: dict = None):
|
|
host = _relative_name(name, zone_id)
|
|
for row in self._raw_records(zone_id, record_type):
|
|
if row.get('name') != host:
|
|
continue
|
|
if caa is not None and (
|
|
row.get('tag') != caa['tag']
|
|
or str(row.get('data', '')).strip('"').rstrip('.').lower()
|
|
!= caa['value'].rstrip('.').lower()
|
|
):
|
|
continue
|
|
return str(row.get('id'))
|
|
return None
|
|
|
|
@staticmethod
|
|
def _payload(zone_id, spec):
|
|
content, priority = _split_priority(spec)
|
|
payload = {
|
|
'type': spec.record_type,
|
|
'name': _relative_name(spec.name, zone_id),
|
|
'data': content,
|
|
'ttl': spec.ttl,
|
|
}
|
|
if priority is not None:
|
|
payload['priority'] = priority
|
|
if spec.record_type == 'CAA':
|
|
caa = parse_caa_value(content)
|
|
payload.update(data=caa['value'], flags=caa['flags'], tag=caa['tag'])
|
|
return payload
|
|
|
|
def upsert(self, zone_id: str, spec: DnsRecordSpec,
|
|
record_id: str = None) -> dict:
|
|
try:
|
|
base = f'{self.API_BASE}/domains/{zone_id}/records'
|
|
if record_id is None:
|
|
caa = parse_caa_value(spec.content) if spec.record_type == 'CAA' else None
|
|
record_id = self.find_record_id(
|
|
zone_id, spec.record_type, spec.name, caa=caa,
|
|
)
|
|
payload = self._payload(zone_id, spec)
|
|
if record_id:
|
|
response = requests.put(
|
|
f'{base}/{record_id}', headers=self._headers(),
|
|
json=payload, timeout=15,
|
|
)
|
|
else:
|
|
response = requests.post(
|
|
base, headers=self._headers(), json=payload, timeout=15,
|
|
)
|
|
data = response.json()
|
|
if response.status_code not in (200, 201):
|
|
return {'success': False, 'error': _http_error(response, data)}
|
|
result_id = (data.get('domain_record') or {}).get('id') or record_id
|
|
return {
|
|
'success': True,
|
|
'record_id': str(result_id) if result_id is not None else None,
|
|
'message': f'{spec.record_type} record set for {spec.name}',
|
|
}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
def delete(self, zone_id: str, *, record_id: str = None,
|
|
record_type: str = None, name: str = None) -> dict:
|
|
try:
|
|
ids = [record_id] if record_id else []
|
|
if not ids:
|
|
host = _relative_name(name, zone_id)
|
|
ids = [
|
|
str(row['id']) for row in self._raw_records(zone_id, record_type)
|
|
if row.get('name') == host
|
|
]
|
|
if not ids:
|
|
return {'success': True, 'message': 'Record not found (already deleted)'}
|
|
for item_id in ids:
|
|
response = requests.delete(
|
|
f'{self.API_BASE}/domains/{zone_id}/records/{item_id}',
|
|
headers=self._headers(), timeout=15,
|
|
)
|
|
if response.status_code == 404:
|
|
continue
|
|
if response.status_code not in (200, 204):
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
data = {}
|
|
return {'success': False, 'error': _http_error(response, data)}
|
|
return {'success': True, 'message': f'{record_type} record deleted for {name}'}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
|
|
class GoDaddyClient:
|
|
"""Normalized GoDaddy record-set client."""
|
|
|
|
API_BASE = 'https://api.godaddy.com/v1'
|
|
|
|
def __init__(self, credential: DnsCredential):
|
|
self.cred = credential
|
|
|
|
def _headers(self):
|
|
return {
|
|
'Authorization': f'sso-key {self.cred.token or ""}:{self.cred.secret or ""}',
|
|
'Content-Type': 'application/json',
|
|
}
|
|
|
|
@staticmethod
|
|
def _record_id(record_type, name):
|
|
return f'{record_type.upper()}:{name}'
|
|
|
|
def _raw_records(self, zone_id, record_type=None, name=None):
|
|
url = f'{self.API_BASE}/domains/{zone_id}/records'
|
|
if record_type:
|
|
url += f'/{record_type}'
|
|
if name is not None:
|
|
url += f'/{name}'
|
|
response = requests.get(url, headers=self._headers(), timeout=15)
|
|
data = response.json()
|
|
if response.status_code != 200:
|
|
raise RuntimeError(_http_error(response, data))
|
|
return data or []
|
|
|
|
@staticmethod
|
|
def _normalize(zone_id, row):
|
|
content = row.get('data', '')
|
|
priority = row.get('priority')
|
|
if row.get('type') == 'CAA':
|
|
content = f'{row.get("flags", 0)} {row.get("tag", "issue")} "{content}"'
|
|
return {
|
|
'id': GoDaddyClient._record_id(row.get('type'), row.get('name')),
|
|
'type': row.get('type'),
|
|
'name': _absolute_name(row.get('name'), zone_id),
|
|
'content': content,
|
|
'ttl': row.get('ttl'),
|
|
'priority': priority,
|
|
'proxied': False,
|
|
}
|
|
|
|
def list_records(self, zone_id: str) -> dict:
|
|
try:
|
|
return {
|
|
'success': True,
|
|
'records': [self._normalize(zone_id, row) for row in self._raw_records(zone_id)],
|
|
}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
def find_record_id(self, zone_id: str, record_type: str, name: str,
|
|
caa: dict = None):
|
|
del caa # GoDaddy PUT addresses/replaces the whole name+type RRset.
|
|
host = _relative_name(name, zone_id)
|
|
rows = self._raw_records(zone_id, record_type, host)
|
|
return self._record_id(record_type, host) if rows else None
|
|
|
|
@staticmethod
|
|
def _payload(spec):
|
|
content, priority = _split_priority(spec)
|
|
row = {'data': content, 'ttl': spec.ttl}
|
|
if priority is not None:
|
|
row['priority'] = priority
|
|
if spec.record_type == 'CAA':
|
|
caa = parse_caa_value(content)
|
|
row.update(data=caa['value'], flags=caa['flags'], tag=caa['tag'])
|
|
return row
|
|
|
|
def upsert(self, zone_id: str, spec: DnsRecordSpec,
|
|
record_id: str = None) -> dict:
|
|
try:
|
|
host = _relative_name(spec.name, zone_id)
|
|
response = requests.put(
|
|
f'{self.API_BASE}/domains/{zone_id}/records/{spec.record_type}/{host}',
|
|
headers=self._headers(), json=[self._payload(spec)], timeout=15,
|
|
)
|
|
if response.status_code not in (200, 201, 204):
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
data = {}
|
|
return {'success': False, 'error': _http_error(response, data)}
|
|
return {
|
|
'success': True,
|
|
'record_id': self._record_id(spec.record_type, host),
|
|
'message': f'{spec.record_type} record set for {spec.name}',
|
|
}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
def delete(self, zone_id: str, *, record_id: str = None,
|
|
record_type: str = None, name: str = None) -> dict:
|
|
try:
|
|
host = _relative_name(name, zone_id)
|
|
response = requests.delete(
|
|
f'{self.API_BASE}/domains/{zone_id}/records/{record_type}/{host}',
|
|
headers=self._headers(), timeout=15,
|
|
)
|
|
if response.status_code in (200, 204, 404):
|
|
message = ('Record not found (already deleted)'
|
|
if response.status_code == 404
|
|
else f'{record_type} record deleted for {name}')
|
|
return {'success': True, 'message': message}
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
data = {}
|
|
return {'success': False, 'error': _http_error(response, data)}
|
|
except Exception as exc:
|
|
return {'success': False, 'error': str(exc)}
|
|
|
|
|
|
def get_record_client(credential: DnsCredential, *, route53_client=None):
|
|
"""Build a normalized record client for every supported provider."""
|
|
provider = (credential.provider or '').lower()
|
|
if provider == 'cloudflare':
|
|
from app.services.dns.cloudflare import CloudflareClient
|
|
return CloudflareClient(credential)
|
|
if provider == 'route53':
|
|
return Route53Client(credential, client=route53_client)
|
|
if provider == 'digitalocean':
|
|
return DigitalOceanClient(credential)
|
|
if provider == 'godaddy':
|
|
return GoDaddyClient(credential)
|
|
raise ValueError(f'No DNS record client for provider: {credential.provider}')
|