Files
2026-07-16 13:03:11 +08:00

229 lines
7.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
阿里云 DNS 解析管理服务
通过阿里云 OpenAPI 管理 zgitm.com 的 DNS 解析记录
使用 HMAC-SHA1 签名,兼容 Python 3.6+
"""
import os
import hmac
import hashlib
import base64
import logging
import urllib.request
import urllib.parse
import urllib.error
import json
import time
import uuid
logger = logging.getLogger(__name__)
# 阿里云 API 配置
ALIDNS_ENDPOINT = 'https://alidns.aliyuncs.com'
API_VERSION = '2015-01-09'
SIGNATURE_METHOD = 'HMAC-SHA1'
SIGNATURE_VERSION = '1.0'
class DnsService:
"""阿里云 DNS 解析管理"""
def __init__(self):
self.access_key_id = os.environ.get('Ali_Key', '')
self.access_key_secret = os.environ.get('Ali_Secret', '')
def _sign(self, method, params):
"""
HMAC-SHA1 签名(阿里云 API 签名 v1.0
"""
# 1. 按参数名排序
sorted_keys = sorted(params.keys())
sorted_params = [(k, params[k]) for k in sorted_keys]
# 2. 构造规范化查询字符串
canonicalized = urllib.parse.urlencode(sorted_params)
# 3. 构造签名源字符串
string_to_sign = method + '&' + urllib.parse.quote('/', safe='') + '&' + urllib.parse.quote(canonicalized, safe='')
# 4. HMAC-SHA1 签名
key = (self.access_key_secret + '&').encode('utf-8')
h = hmac.new(key, string_to_sign.encode('utf-8'), hashlib.sha1)
signature = base64.b64encode(h.digest()).decode('utf-8')
return signature
def _call(self, action, extra_params=None):
"""
调用阿里云 OpenAPI
Args:
action: API 名称 (如 DescribeDomainRecords)
extra_params: 额外参数
Returns:
dict: API 响应
"""
if not self.access_key_id or not self.access_key_secret:
raise RuntimeError('阿里云 AccessKey 未配置')
# 公共参数
params = {
'AccessKeyId': self.access_key_id,
'Action': action,
'Format': 'JSON',
'Version': API_VERSION,
'SignatureMethod': SIGNATURE_METHOD,
'SignatureVersion': SIGNATURE_VERSION,
'SignatureNonce': str(uuid.uuid4()),
'Timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}
if extra_params:
params.update(extra_params)
# 签名
params['Signature'] = self._sign('GET', params)
# 构造 URL
url = ALIDNS_ENDPOINT + '/?' + urllib.parse.urlencode(params)
# 发送请求
try:
req = urllib.request.Request(url, method='GET')
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode('utf-8'))
return result
except urllib.error.HTTPError as e:
body = e.read().decode('utf-8') if e.fp else ''
logger.error(f'DNS API HTTP {e.code}: {body}')
raise RuntimeError(f'DNS API 请求失败: {e.code} {body}')
except Exception as e:
logger.error(f'DNS API 异常: {e}')
raise RuntimeError(f'DNS API 异常: {e}')
def list_records(self, domain_zone, rr_keyword=None, record_type=None):
"""
获取域名解析记录列表
Args:
domain_zone: 主域名 (如 zgitm.com)
rr_keyword: 主机记录筛选 (如 www)
record_type: 记录类型筛选 (如 A, CNAME)
Returns:
list[dict]: 解析记录列表
"""
params = {'DomainName': domain_zone}
if rr_keyword:
params['RRKeyWord'] = rr_keyword
if record_type:
params['Type'] = record_type
resp = self._call('DescribeDomainRecords', params)
records = resp.get('DomainRecords', {}).get('Record', [])
# 统一转列表
if isinstance(records, dict):
records = [records]
result = []
for r in records:
result.append({
'record_id': r.get('RecordId', ''),
'rr': r.get('RR', ''),
'type': r.get('Type', 'A'),
'value': r.get('Value', ''),
'ttl': r.get('TTL', 600),
'line': r.get('Line', 'default'),
'status': r.get('Status', 'ENABLE'),
'full_domain': (r.get('RR', '') + '.' + domain_zone).strip('.'),
})
return result
def add_record(self, domain_zone, rr, record_type, value, ttl=600):
"""
添加解析记录
Args:
domain_zone: 主域名
rr: 主机记录 (如 webssd, @, www)
record_type: 记录类型 (A, CNAME, TXT 等)
value: 记录值 (IP地址或域名)
ttl: TTL 秒数
Returns:
dict: {'record_id': ..., 'full_domain': ...}
"""
params = {
'DomainName': domain_zone,
'RR': rr,
'Type': record_type.upper(),
'Value': value,
'TTL': str(ttl),
}
resp = self._call('AddDomainRecord', params)
record_id = resp.get('RecordId', '')
full_domain = (rr + '.' + domain_zone).strip('.').replace('@.', '')
logger.info(f'DNS 记录已添加: {full_domain} {record_type} {value} (ID: {record_id})')
return {'record_id': record_id, 'full_domain': full_domain}
def delete_record(self, record_id):
"""
删除解析记录
Args:
record_id: 记录ID
Returns:
bool: 是否成功
"""
resp = self._call('DeleteDomainRecord', {'RecordId': str(record_id)})
logger.info(f'DNS 记录已删除: {record_id}')
return True
def update_record(self, record_id, rr, record_type, value, ttl=600):
"""
修改解析记录
Args:
record_id: 记录ID
rr: 主机记录
record_type: 记录类型
value: 记录值
ttl: TTL
Returns:
bool: 是否成功
"""
params = {
'RecordId': str(record_id),
'RR': rr,
'Type': record_type.upper(),
'Value': value,
'TTL': str(ttl),
}
try:
self._call('UpdateDomainRecord', params)
logger.info(f'DNS 记录已修改: {record_id}{rr} {record_type} {value}')
except RuntimeError as e:
msg = str(e)
# 未做任何改动直接保存,阿里云返回 DomainRecordDuplicate视为成功
if 'DomainRecordDuplicate' in msg:
logger.info(f'DNS 记录未变更,跳过: {record_id} {rr} {record_type} {value}')
else:
raise
return True
def set_record_status(self, record_id, status):
"""
启用/停用解析记录
Args:
record_id: 记录ID
status: Enable / Disable
"""
self._call('SetDomainRecordStatus', {
'RecordId': str(record_id),
'Status': status.upper(),
})
logger.info(f'DNS 记录状态已更新: {record_id}{status}')
return True