Files
aaPanel/class/panelDnsapi.py
T
2026-04-16 18:17:07 +08:00

2154 lines
85 KiB
Python

# coding: utf-8
# +-------------------------------------------------------------------
# | aaPanel
# +-------------------------------------------------------------------
# | Copyright (c) 2015-2099 aaPanel(www.aapanel.com) All rights reserved.
# +-------------------------------------------------------------------
# | Author: aaPanel
# +-------------------------------------------------------------------
import ipaddress
import os
import re
import sys
import time
from datetime import datetime
from typing import Optional
from urllib.parse import urljoin
import requests
os.chdir("/www/server/panel")
sys.path.insert(0, "class/")
sys.path.insert(0, "class_v2/")
import public
from public.exceptions import HintException
__all__ = [
"aaPanelDns",
"NameSiloDns",
"NameCheapDns",
"CloudFlareDns",
"PorkBunDns",
"GodaddyDns",
"ClouDns",
"SpaceShipDNS",
]
class ExtractZoneTool(object):
def __call__(self, domain_name):
root, zone = public.split_domain_sld(domain_name)
if not zone:
acme_txt = "_acme-challenge"
else:
acme_txt = "_acme-challenge.%s" % zone
return root, zone, acme_txt
extract_zone = ExtractZoneTool()
def sync_log(body, mode="a"):
dns_sync_log = os.path.join(public.get_panel_path(), "logs/dns_sync.log")
body += "\n"
with open(dns_sync_log, mode) as f:
f.write(body)
def white_kwargs(body: dict, kw_prefix: dict, kwargs: dict) -> dict:
"""
kwargs: create 从 kw获取, update 从 new_record 获取
"""
# when create or update
if kwargs.get("priority") and kwargs.get("priority") != -1: # 接受其他关键参数
if kw_prefix.get("priority"):
body[kw_prefix.get("priority")] = int(kwargs.get("priority"))
return body
class BaseDns(object):
def __init__(self):
self.dns_provider_name = self.__class__.__name__
self.api_user = ""
self.api_key = ""
def log_response(self, response: requests.Response):
try:
log_body = response.json()
except ValueError:
log_body = response.content
return log_body
# =============== acme ======================
def create_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
raise NotImplementedError("create_dns_record method must be implemented.")
def delete_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
raise NotImplementedError("delete_dns_record method must be implemented.")
# =============== 域名管理同步信息 =====================
def get_domains(self, **kwargs) -> list:
raise NotImplementedError("get_domains method must be implemented.")
def get_dns_record(self, domain_name: str) -> list:
raise NotImplementedError("get_dns_record method must be implemented.")
def create_org_record(
self, domain_name: str, record: str, record_value: str, record_type: str, ttl: int, **kwargs
) -> Optional[dict]:
raise NotImplementedError("create_org_record method must be implemented.")
def remove_record(self, domain_name: str, record: str, record_type: str, **kwargs) -> Optional[dict]:
raise NotImplementedError("remove_record method must be implemented.")
def update_record(self, domain_name: str, record: dict, new_record: dict, **kwargs) -> Optional[dict]:
raise NotImplementedError("update_record method must be implemented.")
def raise_resp_error(self, response: requests.Response):
raise HintException(
"Error {dns_name}: status_code={status_code} response={response}".format(
dns_name=self.dns_provider_name,
status_code=response.status_code,
response=self.log_response(response),
)
)
def verify(self) -> Optional[bool]:
try:
self.get_domains()
except Exception:
raise HintException("Verify fail, please check your Api Account and Password")
return True
class aaPanelDns(BaseDns):
"""
遵循 ssl v2入参, 转发dnsmanager
"""
dns_provider_name = "aapanel"
kw_prefix = {
"priority": "priority"
}
def __init__(self, api_user: str = None, api_key: str = None, **kwargs): # noqa
super().__init__()
self.api_user = api_user
self.api_key = api_key
from ssl_dnsV2.dns_manager import DnsManager
self.manager = DnsManager()
# ============== acme ======================
def create_dns_record(self, domain_name, domain_dns_value):
domain_name = domain_name.lstrip("*.")
_, _, acme_txt = extract_zone(domain_name)
self.create_org_record(
domain_name=domain_name,
record=acme_txt,
record_value=domain_dns_value,
record_type="TXT",
ttl=600,
)
def delete_dns_record(self, domain_name, domain_dns_value) -> None:
domain_name = domain_name.lstrip("*.")
root, _, acme_txt = extract_zone(domain_name)
self.remove_record(root, acme_txt, "TXT", record_value=domain_dns_value)
# =============== 域名管理 ====================
def get_domains(self) -> list:
return self.manager.get_domains()
def get_dns_record(self, domain_name: str) -> list:
records = []
for x in self.manager.parser.get_zones_records(domain_name):
try:
# todo 更多类型特俗处理
if x.get("type") == "SOA":
continue
if x.get("type") == "MX":
priority = re.findall(r"^\s*(\d+)\s+", x.get("value"))
if priority:
x["priority"] = int(priority[0])
record = {
"record": x.get("name"),
"record_type": x.get("type"),
"record_value": x.get("value"),
"ttl": x.get("ttl"),
"proxy": x.get("proxy", -1),
"priority": x.get("priority", -1),
}
records.append(record)
except Exception as e:
public.print_log(f"aaPanelDns get_dns_record error: {e}")
continue
return records
def create_org_record(self, domain_name, record, record_value, record_type, ttl=600, **kwargs):
root, _, _ = extract_zone(domain_name)
body = {
"name": record,
"type": record_type.upper(),
"value": record_value,
"ttl": ttl,
"proxy": kwargs.get("proxy", -1),
"priority": kwargs.get("priority", -1),
}
body = white_kwargs(body, self.kw_prefix, kwargs)
try:
self.manager.add_record(
domain=root, **body
)
return {"status": True, "msg": "Success"}
except HintException as he:
return {"status": False, "msg": str(he)}
except Exception as e:
return {"status": False, "msg": str(e)}
def remove_record(self, domain_name, record, record_type="TXT", **kwargs) -> dict:
root, _, _ = extract_zone(domain_name)
body = {
"name": record,
"type": record_type.upper(),
}
if kwargs.get("record_value"):
body["value"] = kwargs.get("record_value")
try:
self.manager.delete_record(
domain=root, **body
)
return {"status": True, "msg": "Success"}
except HintException as he:
return {"status": False, "msg": str(he)}
except Exception as e:
return {"status": False, "msg": str(e)}
def update_record(self, domain_name: str, record: dict, new_record: dict, **kwargs):
domain, _, _ = extract_zone(domain_name)
body = {
"name": record.get("record"),
"type": record.get("record_type").upper(),
"value": record.get("record_value"),
"ttl": record.get("ttl", 600),
"priority": record.get("priority", 10),
"proxy": record.get("proxy", -1),
"new_record": {
"name": new_record.get("record"),
"type": new_record.get("record_type").upper(),
"value": new_record.get("record_value"),
"ttl": new_record.get("ttl", 600),
"proxy": new_record.get("proxy", -1),
"priority": new_record.get("priority", -1),
}
}
try:
self.manager.update_record(
domain=domain, **body
)
return {"status": True, "msg": "Success"}
except HintException as he:
return {"status": False, "msg": str(he)}
except Exception as e:
return {"status": False, "msg": str(e)}
def verify(self) -> bool:
if os.path.exists(public.get_panel_path() + "/class_v2/ssl_dnsV2/aadns.pl"):
return True
return False
# noinspection PyUnusedLocal
class NameCheapDns(BaseDns):
dns_provider_name = "namecheap"
kw_prefix = {
"priority": "MXPref"
}
def __init__(self, api_user, api_key, **kwargs):
super().__init__()
self.api_user = api_user
self.api_key = api_key
self.timeout = 30
self.base_url = "https://api.namecheap.com/xml.response"
def _get_local_ip(self):
ip = public.GetLocalIp()
if isinstance(ipaddress.ip_address(ip), ipaddress.IPv6Address):
raise HintException("Namecheap Api does not support IPv6")
return ip
def _get_hosts(self, domain_name) -> list:
time.sleep(2) # 限流
params = {
"ApiUser": self.api_user,
"ApiKey": self.api_key,
"UserName": self.api_user,
"Command": "namecheap.domains.dns.getHosts",
"ClientIp": self._get_local_ip(),
"SLD": domain_name.split(".")[0],
"TLD": domain_name.split(".")[1],
}
resp = requests.get(url=self.base_url, params=params, timeout=self.timeout)
if resp.status_code != 200:
self.raise_resp_error(resp)
hosts = []
index = 0
hosts_info = self._generate_xml_tree(resp.text, ".//host")
for host in hosts_info:
index += 1
try:
ttl = int(host.get("TTL", 1))
except Exception:
ttl = 1
try:
mx_pref = int(host.get("MXPref", -1))
except Exception:
mx_pref = -1
hosts.append({
f"HostName{index}": host.get("Name"),
f"RecordType{index}": host.get("Type"),
f"Address{index}": host.get("Address"),
f"TTL{index}": ttl,
f"MXPref{index}": mx_pref if host.get("Type") == "MX" else -1,
})
return hosts
# =============== acme ======================
def create_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
# acme 调用
domain_name = domain_name.lstrip("*.")
_, _, acme_txt = extract_zone(domain_name)
self.create_org_record(
domain_name=domain_name,
record=acme_txt,
record_value=domain_dns_value,
record_type="TXT",
)
def delete_dns_record(self, domain_name, dns_value=None) -> None:
# 移除挑战值
_, _, dns_name = extract_zone(domain_name)
self.remove_record(domain_name, dns_name, "TXT")
# =============== 域名管理 ====================
@staticmethod
def _generate_xml_tree(resp_body: str, findall: str):
import xml.etree.ElementTree as EtTree
from xml.etree.ElementTree import ParseError as ETParseError
# noinspection HttpUrlsUsage
tree_root = resp_body.replace('xmlns="http://api.namecheap.com/xml.response"', '')
try:
targets = EtTree.fromstring(tree_root).findall(findall)
return targets
except ETParseError:
raise HintException("Error parsing XML response from Namecheap API")
except Exception as e:
raise HintException(e)
def get_domains(self, verify: bool = False) -> list | bool:
# 获取账号下所有域名, 判断域名nameserver归属, 并且所有返回均为xml
domains = []
page = 1
while 1:
params = {
"ApiUser": self.api_user,
"ApiKey": self.api_key,
"UserName": self.api_user,
"Command": "namecheap.domains.getList", # returns a list of domains for the particular user
"ClientIp": self._get_local_ip(),
"Page": page,
"PageSize": 100,
"SortBy": "NAME",
}
resp = requests.get(url=self.base_url, params=params, timeout=self.timeout)
if "API Key is invalid or API access has not been enabled" in resp.text:
raise HintException("API Key is invalid or API access has not been enabled")
if "ERROR" in resp.text:
try:
err_msg = re.search(r"<Errors>(.*?)</Errors>", resp.text, re.DOTALL).group(1)
except:
err_msg = resp.text
raise HintException(err_msg)
if verify:
return True
try:
temp_domains = self._generate_xml_tree(resp.text, ".//Domain")
except HintException as hit:
public.print_log(hit)
continue
if not temp_domains:
break
time.sleep(3) # 限流
domains.extend(temp_domains)
page += 1
for expired in [d.get("Name", "") for d in domains if d.get("IsExpired") == "true"]:
sync_log(f"|-- warning: [{expired}] is Expired, Skip It...")
domains = [
domain.get("Name") for domain in domains if domain.get("IsExpired") == "false"
]
res = []
for d in domains:
try:
params = {
"ApiUser": self.api_user,
"ApiKey": self.api_key,
"UserName": self.api_user,
# gets a list of DNS servers associated with the requested domain.
"Command": "namecheap.domains.dns.getList",
"ClientIp": self._get_local_ip(),
"SLD": d.split(".")[0],
"TLD": d.split(".")[1],
}
resp = requests.get(url=self.base_url, params=params, timeout=self.timeout)
if resp.status_code != 200:
continue
time.sleep(3) # 限流
for t in self._generate_xml_tree(resp.text, ".//DomainDNSGetListResult"):
if t.get("Domain") == d:
if t.get("IsUsingOurDNS") == "true":
res.append(d)
break
else:
sync_log(
f"|-- warning: [{t.get('Domain')}] is Not Using NameCheap DNS, Skip It..."
)
break
except Exception as e:
public.print_log(f"get_domains error {e}")
continue
return res
# nc 所有record 不带域名
def get_dns_record(self, domain_name: str):
domain_name, _, _ = extract_zone(domain_name)
try:
records = self._get_hosts(domain_name)
except Exception as e:
public.print_log(f"get_dns_record error {e}")
records = []
res = []
for r in records:
temp = {}
for k, v in r.items():
if k.startswith("HostName"):
temp["record"] = v
elif k.startswith("RecordType"):
temp["record_type"] = v
elif k.startswith("Address"):
temp["record_value"] = v
elif k.startswith("TTL"):
temp["ttl"] = r.get("ttl", 1)
elif k.startswith("MXPref"):
temp["priority"] = v
else:
temp[k] = v
res.append(temp)
return res
def __set_hosts_with_params(self, domain_name: str, new_params: dict):
try:
setHosts_resp = requests.get(url=self.base_url, params=new_params, timeout=self.timeout)
except Exception as e:
return {"status": False, "msg": str(e)}
if any([
setHosts_resp.status_code != 200,
f'Domain="{domain_name}" IsSuccess="true"' not in setHosts_resp.text
]):
return {"status": False, "msg": setHosts_resp.text}
return {"status": True, "msg": setHosts_resp.text}
def __generate_new_params(self, new_params: dict, new_hosts: list) -> dict:
for i, host in enumerate(new_hosts):
index = i + 1
for key in list(host.keys()):
if key.startswith(("HostName", "Address", "RecordType", "TTL", "MXPref")):
base_key = ''.join([c for c in key if not c.isdigit()])
if base_key == "MXPref" and host[key] == -1:
continue
new_params[f"{base_key}{index}"] = host[key]
return new_params
# 创建record
def create_org_record(self, domain_name, record, record_value, record_type, ttl=1, **kwargs):
domain_name, _, _ = extract_zone(domain_name)
hosts = self._get_hosts(domain_name)
add_index = len(hosts) + 1
params = {
"ApiUser": self.api_user,
"ApiKey": self.api_key,
"UserName": self.api_user,
"ClientIp": self._get_local_ip(),
"Command": "namecheap.domains.dns.setHosts",
"SLD": domain_name.split(".")[0],
"TLD": domain_name.split(".")[1],
"DomainName": domain_name,
}
# nc 单独处理
for index, host in enumerate(hosts):
idx = index + 1
for field in ["HostName", "Address", "RecordType", "TTL", "MXPref"]:
if field == "MXPref" and host[f"{field}{idx}"] == -1:
continue
params[f"{field}{idx}"] = host[f"{field}{idx}"]
params.update({
f"HostName{add_index}": record,
f"Address{add_index}": record_value,
f"RecordType{add_index}": record_type,
f"TTL{add_index}": ttl
})
return self.__set_hosts_with_params(domain_name, params)
# 删除record
def remove_record(self, domain_name, record, record_type="TXT", **kwargs) -> dict:
domain_name, _, _ = extract_zone(domain_name)
hosts_info = self._get_hosts(domain_name)
new_hosts = [
host for host in hosts_info if not (record in host.values() and record_type in host.values())
]
if not new_hosts:
# is empty
return {"status": True, "msg": "Dns Record is empty."}
new_params = {
"ApiUser": self.api_user,
"ApiKey": self.api_key,
"UserName": self.api_user,
"ClientIp": self._get_local_ip(),
"Command": "namecheap.domains.dns.setHosts",
"SLD": domain_name.split(".")[0],
"TLD": domain_name.split(".")[1],
"DomainName": domain_name,
}
new_params = self.__generate_new_params(new_params, new_hosts)
return self.__set_hosts_with_params(domain_name, new_params)
# 更新record
def update_record(self, domain_name: str, record: dict, new_record: dict, **kwargs):
domain_name, _, _ = extract_zone(domain_name)
hosts_info = self._get_hosts(domain_name)
new_hosts = []
for index, host in enumerate(hosts_info):
if all([
record.get("record") in host.values(),
record.get("record_type") in host.values(),
record.get("record_value") in host.values(),
]):
host[f"HostName{index + 1}"] = new_record.get("record")
host[f"RecordType{index + 1}"] = new_record.get("record_type")
host[f"Address{index + 1}"] = new_record.get("record_value")
host[f"TTL{index + 1}"] = kwargs.get("ttl", 1)
if new_record.get("priority") != -1:
host[f"{self.kw_prefix.get("priority")}{index + 1}"] = new_record.get("priority")
new_hosts.append(host)
else:
new_hosts.append(host)
if not new_hosts: # is empty
return {"status": True, "msg": "Dns Record is empty."}
new_params = {
"ApiUser": self.api_user,
"ApiKey": self.api_key,
"UserName": self.api_user,
"ClientIp": self._get_local_ip(),
"Command": "namecheap.domains.dns.setHosts",
"SLD": domain_name.split(".")[0],
"TLD": domain_name.split(".")[1],
"DomainName": domain_name,
}
new_params = self.__generate_new_params(new_params, new_hosts)
return self.__set_hosts_with_params(domain_name, new_params)
# 验证
def verify(self):
# namecheap token 请求时会切掉多余的str长度
try:
self.get_domains(verify=True)
except HintException as e:
raise e
except Exception:
raise HintException(
"Verify fail, please check your Api Account and Password, "
"Add the server address to the NameCheapDns API whitelist."
)
return True
# noinspection PyUnusedLocal
class CloudFlareDns(BaseDns):
dns_provider_name = "cloudflare"
kw_prefix = {
"priority": "priority"
}
def __init__(self, api_user, api_key, limit: bool = True, **kwargs):
super().__init__()
self.cf_zone_id = None
self.api_user = api_user
self.api_key = api_key
self.limit = limit
self.cf_base_url = "https://api.cloudflare.com/client/v4/"
self.time_out = 65 # seconds
def _get_auth_headers(self) -> dict:
if self.limit:
return {"Authorization": "Bearer " + self.api_key}
else: # api limit False, is global permissions
return {"X-Auth-Email": self.api_user, "X-Auth-Key": self.api_key}
def find_dns_zone(self, domain_name):
url = self.cf_base_url + "zones?status=active&per_page=1000"
headers = self._get_auth_headers()
find_dns_zone_response = requests.get(url, headers=headers, timeout=self.time_out)
if find_dns_zone_response.status_code != 200:
raise HintException(
"Error find cloudflare dns zone, please check your Api Account and Password:"
" status_code={status_code} response={response}".format(
status_code=find_dns_zone_response.status_code,
response=self.log_response(find_dns_zone_response),
)
)
result = find_dns_zone_response.json()["result"]
domain = domain_name.lstrip("*.")
matched_zones = [
x for x in result if domain == x.get("name") or domain.endswith("." + x.get("name"))
]
if not matched_zones:
raise HintException(
"Error unable to get DNS zone for domain={domain}".format(
domain=domain
)
)
# 优先最长匹配, max时len相等, 即eq, not eq时max为最精确
best_match = max(matched_zones, key=lambda x: len(x.get("name", "")))
setattr(self, "cf_zone_id", best_match.get("id"))
if self.cf_zone_id is None:
raise HintException(
"Error unable to get DNS zone for domain={domain} "
", please check your Api Account and Password: status_code={status_code} response={response}".format(
domain=domain,
status_code=find_dns_zone_response.status_code,
response=self.log_response(find_dns_zone_response),
)
)
# =============== acme ======================
def create_dns_record(self, domain_name, domain_dns_value):
# acme 调用
domain_name = domain_name.lstrip("*.")
self.find_dns_zone(domain_name)
url = urljoin(
self.cf_base_url,
"zones/{0}/dns_records".format(self.cf_zone_id),
)
headers = self._get_auth_headers()
body = {
"type": "TXT",
"name": "_acme-challenge" + "." + domain_name + ".",
"content": "{0}".format(domain_dns_value),
}
create_cloudflare_dns_record_response = requests.post(
url, headers=headers, json=body, timeout=self.time_out
)
if create_cloudflare_dns_record_response.status_code != 200:
# raise error so that we do not continue to make calls to ACME
# server
raise HintException(
"Error creating cloudflare dns record: status_code={status_code} response={response}".format(
status_code=create_cloudflare_dns_record_response.status_code,
response=self.log_response(create_cloudflare_dns_record_response),
)
)
def delete_dns_record(self, domain_name, domain_dns_value):
# 移除挑战值
domain_name, _, acme_txt = extract_zone(domain_name)
acme_txt = acme_txt + "." + domain_name
self.remove_record(domain_name, acme_txt, "TXT")
# =============== 域名管理 ====================
def get_domains(self) -> list:
url = self.cf_base_url + "zones?status=active&order=name"
headers = self._get_auth_headers()
domains = []
page = 1
count = 1
fail_count = 0
while count != 0:
params = {"page": page, "per_page": 50}
try:
res = requests.get(url, headers=headers, params=params, timeout=self.time_out)
time.sleep(1) # 限流
resp = res.json()
if not resp.get("success"):
fail_count += 1
if fail_count >= 3:
raise HintException("get domains fail")
else:
time.sleep(1)
continue
result = resp.get("result", [])
count = resp.get("result_info", {}).get("count", 0)
domains.extend([i.get("name", "") for i in result])
page += 1
except Exception as e:
public.print_log(f"cloudflare get_domains error {e}")
raise HintException(e)
return domains
def get_dns_record(self, domain_name: str) -> list:
domain_name, _, _ = extract_zone(domain_name)
self.find_dns_zone(domain_name)
url = self.cf_base_url + f"zones/{self.cf_zone_id}/dns_records"
result = []
page = 1
per_page = 500
fail_count = 0
while True:
params = {"page": page, "per_page": per_page}
try:
response = requests.get(
url, headers=self._get_auth_headers(), params=params
)
data = response.json()
if data.get("success"):
records = data.get("result", [])
result.extend([
{
"record": i.get("name", ""),
"record_value": i.get("content", ""),
"record_type": i.get("type", ""),
"proxy": i.get("proxied", False),
"priority": i.get("priority", -1),
"ttl": i.get("ttl", 1),
} for i in records
])
if len(records) < per_page:
break
page += 1
else:
fail_count += 1
if fail_count >= 3:
break
except requests.RequestException as e:
public.print_log(f"get_dns_record error {e}")
break
return result
# 创建record
def create_org_record(self, domain_name, record, record_value, record_type, ttl=1, proxied=0, **kwargs):
domain_name, _, _ = extract_zone(domain_name)
proxied = True if proxied == 1 else False
self.find_dns_zone(domain_name)
url = self.cf_base_url + f"zones/{self.cf_zone_id}/dns_records"
headers = self._get_auth_headers()
body = {
"content": record_value,
"name": record,
"proxied": proxied,
"ttl": ttl,
"type": record_type
}
body = white_kwargs(body, self.kw_prefix, kwargs) # 接受其他关键参数
try:
create_res = requests.post(url, headers=headers, json=body, timeout=self.time_out)
create_res = create_res.json()
if create_res.get("success"):
return {"status": True, "msg": create_res}
return {"status": False, "msg": str(create_res.get("errors"))}
except requests.exceptions.HTTPError as http_err:
return {"status": False, "msg": http_err}
except Exception as e:
return {"status": False, "msg": str(e)}
# 删除record
def remove_record(self, domain_name, record, record_type="TXT", **kwargs) -> dict:
domain_name, _, _ = extract_zone(domain_name)
self.find_dns_zone(domain_name)
headers = self._get_auth_headers()
list_dns_payload = {"type": record_type, "name": record}
list_dns_url = self.cf_base_url + f"zones/{self.cf_zone_id}/dns_records"
list_dns_response = requests.get(
list_dns_url, params=list_dns_payload, headers=headers, timeout=self.time_out
)
try:
for i in range(0, len(list_dns_response.json()["result"])):
dns_record_id = list_dns_response.json()["result"][i]["id"]
url = self.cf_base_url + f"zones/{self.cf_zone_id}/dns_records/{dns_record_id}"
remove_res = requests.delete(url, headers=headers, timeout=self.time_out)
remove_res = remove_res.json()
if remove_res.get("success"):
return {"status": True, "msg": remove_res}
return {"status": False, "msg": str(remove_res.get("errors"))}
# is empty
return {"status": True, "msg": "Dns Record is empty."}
except requests.exceptions.HTTPError as http_err:
return {"status": False, "msg": http_err}
except Exception as e:
return {"status": False, "msg": str(e)}
# 更新record
def update_record(self, domain_name, record: dict, new_record: dict, **kwargs):
domain_name, _, _ = extract_zone(domain_name)
self.find_dns_zone(domain_name)
record_type = record.get("record_type")
record_name = record.get("record")
get_url = self.cf_base_url + f"zones/{self.cf_zone_id}/dns_records?type={record_type}&name={record_name}"
try:
# get record id
get_response = requests.get(get_url, headers=self._get_auth_headers())
get_result = get_response.json()
if get_result.get("success") and get_result.get("result"):
record_id = get_result["result"][0]["id"]
update_url = self.cf_base_url + f"zones/{self.cf_zone_id}/dns_records/{record_id}"
body = {
"type": new_record.get("record_type"),
"name": new_record.get("record"),
"content": new_record.get("record_value"),
"ttl": new_record.get("ttl", 1),
"proxied": True if new_record.get("proxy") == 1 else False,
}
body = white_kwargs(body, self.kw_prefix, new_record)
update_response = requests.put(update_url, headers=self._get_auth_headers(), json=body)
update_result = update_response.json()
if update_result.get("success"):
return {"status": True, "msg": update_result}
return {"status": False, "msg": str(update_result.get("errors"))}
else:
return {"status": False, "msg": "Dns Record Not Found!"}
except requests.exceptions.HTTPError as http_err:
return {"status": False, "msg": http_err}
except Exception as err:
return {"status": False, "msg": err}
# 验证
def verify(self):
try:
self.get_domains()
except Exception:
raise HintException("Verify fail, please check your Api Account and Password")
return True
# noinspection PyUnusedLocal
class PorkBunDns(BaseDns):
dns_provider_name = "porkbun"
kw_prefix = {
"priority": "prio"
}
def __init__(self, api_user, api_key, **kwargs):
super().__init__()
self.api_user = api_user # secretapikey
self.api_key = api_key
self.timeout = 30
self.base_url = "https://api.porkbun.com/api/json/v3"
def _json_data(self) -> dict:
return {
"secretapikey": self.api_user,
"apikey": self.api_key,
}
def _retrieve_record_by_domain(self, domain_name: str) -> list:
domain_name, _, _ = extract_zone(domain_name)
url = self.base_url + f"/dns/retrieve/{domain_name}"
data = self._json_data()
try:
response = requests.post(url, json=data, timeout=self.timeout)
time.sleep(1) # 限流
res = response.json()
if res.get("status") != "SUCCESS":
# 如果没有任何记录,porkbun可能会返回错误
if "domain not found" in res.get("message", "").lower():
return []
raise HintException("get record fail")
return res.get("records", [])
except Exception as err:
raise HintException(err)
def _get_sub_domain(self, root: str, record: str) -> str:
"""
标准化子域名处理:将record 转换为子域名部分
例如: root="example.com", record="www.example.com" -> 返回 "www"
例如: root="example.com", record="example.com" -> 返回 ""
"""
if not record or record == root:
return ""
if record.endswith("." + root):
return record[:-(len(root) + 1)]
return record
def _get_fqdn(self, root: str, record: str) -> str:
"""
标准化为完整域名,用于匹配 retrieve 返回的结果
"""
sub = self._get_sub_domain(root, record)
return f"{sub}.{root}" if sub else root
# =============== acme ======================
def create_dns_record(self, domain_name, domain_dns_value) -> None:
if domain_name.startswith("*."):
domain_name = domain_name[2:]
root, _, acme_txt = extract_zone(domain_name)
self.create_org_record(
domain_name=root,
record=acme_txt,
record_value=domain_dns_value,
record_type="TXT",
)
def delete_dns_record(self, domain_name, domain_dns_value) -> None:
if domain_name.startswith("*."):
domain_name = domain_name[2:]
root, _, acme_txt = extract_zone(domain_name)
fqdn = f"{acme_txt}.{root}" if acme_txt else root
self.remove_record(root, fqdn, "TXT")
# =============== 域名管理 ====================
def get_domains(self) -> list:
url = self.base_url + "/domain/listAll"
data = self._json_data()
response = requests.post(url, json=data, timeout=self.timeout)
res = response.json()
if not res.get("status") == "SUCCESS":
raise HintException("get domains fail")
res_domains = []
for d in res.get("domains"):
if d.get("status") != "ACTIVE":
sync_log(f"|-- warning: [{d.get('domain')}] is Not ACTIVE, Skip It...")
continue
if d.get("expireDate") and d.get("expireDate") < datetime.now().strftime("%Y-%m-%d %H:%M:%S") and \
d.get("expireDate") != "0000-00-00 00:00:00":
sync_log(f"|-- warning: [{d.get('domain')}] is Expired, Skip It...")
continue
try:
detail_url = self.base_url + f"/domain/getNs/{d.get('domain')}"
detail_resp = requests.post(detail_url, json=data, timeout=self.timeout)
detail_res = detail_resp.json()
if not detail_res.get("status") == "SUCCESS":
continue
if all("porkbun.com" in d.lower() for d in detail_res.get("ns")):
res_domains.append(d.get("domain"))
else:
sync_log(f"|-- warning: [{d.get('domain')}] is Not Using Porkbun DNS, Skip It...")
except Exception as err:
public.print_log(f"{self.dns_provider_name} get domains detail error {err}")
continue
res_domains.sort()
return res_domains
def get_dns_record(self, domain_name: str) -> list:
try:
return [
{
"record": x.get("name"), # Porkbun 返回的是 FQDN
"record_value": x.get("content"),
"record_type": x.get("type"),
"proxy": False,
"ttl": int(x.get("ttl", 1)),
"priority": int(x.get("prio", -1)) if x.get("prio") not in [None, "0"] else -1,
} for x in self._retrieve_record_by_domain(domain_name)
]
except Exception as err:
public.print_log(f"{self.dns_provider_name} get_domains error {err}")
raise HintException(err)
def create_org_record(self, domain_name, record, record_value, record_type, ttl=1, **kwargs):
root, _, _ = extract_zone(domain_name)
sub_name = self._get_sub_domain(root, record)
url = self.base_url + f"/dns/create/{root}"
data = self._json_data()
data.update({
"name": sub_name,
"type": record_type,
"content": record_value,
"ttl": 600 if ttl == 1 else int(ttl),
})
data = white_kwargs(data, self.kw_prefix, kwargs) # 接受其他关键参数
try:
response = requests.post(url, json=data, timeout=self.timeout)
res = response.json()
if res.get("status") != "SUCCESS":
return {"status": False, "msg": res.get("message")}
return {"status": True, "msg": res.get("status")}
except Exception as err:
return {"status": False, "msg": err}
def remove_record(self, domain_name, record, record_type="TXT", **kwargs) -> dict:
try:
root, _, _ = extract_zone(domain_name)
# 无论输入,都标准化为 FQDN 进行匹配
target_fqdn = self._get_fqdn(root, record).lower()
res = {"status": "False", "msg": "Dns Record is Not Found."}
for r in self._retrieve_record_by_domain(domain_name):
if r.get("name", "").lower() == target_fqdn and r.get("type", "").lower() == record_type.lower():
url = self.base_url + f"/dns/delete/{root}/{int(r.get('id'))}"
response = requests.post(url, json=self._json_data(), timeout=self.timeout)
res = response.json()
break
if res.get("status") != "SUCCESS":
return {"status": False, "msg": res.get("message", "Fail, please try again later.")}
return {"status": True, "msg": res.get("status", "SUCCESS")}
except Exception as err:
public.print_log(f"{self.dns_provider_name} remove record error {err}")
return {"status": False, "msg": err}
def update_record(self, domain_name, record: dict, new_record: dict, **kwargs):
try:
root, _, _ = extract_zone(domain_name)
# 无论输入,都标准化为 FQDN 进行匹配
old_fqdn = self._get_fqdn(root, record.get("record")).lower()
res = {"status": "False", "msg": "Dns Record is Not Found."}
data = self._json_data()
data.update({
"name": new_record.get("record"),
"type": new_record.get("record_type"),
"content": new_record.get("record_value"),
"ttl": 600 if new_record.get("ttl") == 1 else int(new_record.get("ttl")),
})
data = white_kwargs(data, self.kw_prefix, new_record) # 接受其他关键参数
for r in self._retrieve_record_by_domain(domain_name):
if all([
r.get("name", "").lower() == old_fqdn,
r.get("type", "").lower() == record.get("record_type", "").lower(),
r.get("content") == record.get("record_value"),
]):
url = self.base_url + f"/dns/edit/{root}/{int(r.get('id'))}"
response = requests.post(url, json=data, timeout=self.timeout)
res = response.json()
break
if res.get("status") != "SUCCESS":
return {"status": False, "msg": res.get("message", "Fail, please try again later.")}
return {"status": True, "msg": res.get("status", "SUCCESS")}
except Exception as err:
public.print_log(f"{self.dns_provider_name} remove record error {err}")
return {"status": False, "msg": err}
def verify(self):
try:
url = self.base_url + "/ping"
data = self._json_data()
response = requests.post(url, json=data, timeout=self.timeout)
res = response.json()
if res.get("status") == "SUCCESS":
return True
except:
pass
raise HintException("Verify fail, please check your Api Account and Password")
# noinspection PyUnusedLocal
class NameSiloDns(BaseDns):
dns_provider_name = "namesilo"
kw_prefix = {
"priority": "rrdistance"
}
def __init__(self, api_user, api_key, **kwargs):
super().__init__()
self.api_user = api_user
self.api_key = api_key
self.timeout = 30
self.base_url = "https://www.namesilo.com/api"
self.ver_type_key = f"version=1&type=json&key={api_key}"
def _remote_record(self, domain_name: str) -> list:
try:
url = f"{self.base_url}/dnsListRecords?{self.ver_type_key}&domain={domain_name}"
response = requests.get(url, params={"page": 1, "pageSize": 100})
time.sleep(1) # 限流
res = response.json()
if res.get("reply").get("code") != 300:
raise HintException(res.get("reply").get("detail"))
return res.get("reply", {}).get("resource_record", [])
except Exception as e:
public.print_log(f"get_dns_record error {e}")
raise HintException(e)
def _find_rrid(self, domain_name: str, record: dict) -> str:
rrid = ""
try:
for r in self._remote_record(domain_name):
if all([
r.get("host") == record.get("record"),
r.get("type") == record.get("record_type"),
]):
rrid = r.get("record_id")
break
except Exception as e:
public.print_log(f"NameSilo find rrid error {e}")
return rrid
# =============== acme ======================
def create_dns_record(self, domain_name, domain_dns_value) -> None:
_, _, acme_txt = extract_zone(domain_name)
self.create_org_record(
domain_name=domain_name,
record=acme_txt,
record_value=domain_dns_value,
record_type="TXT",
ttl=3600,
)
def delete_dns_record(self, domain_name, domain_dns_value) -> None:
domain_name, _, acme_txt = extract_zone(domain_name)
acme_txt = acme_txt + "." + domain_name
self.remove_record(domain_name, acme_txt, "TXT")
# =============== 域名管理 ====================
def get_domains(self, verify: bool = False) -> list | bool:
url = f"{self.base_url}/listDomains?{self.ver_type_key}&withBid=1&skipExpired=1"
page = 1
domains = []
fail_count = 0
while 1:
params = {"page": page, "pageSize": 100}
try:
response = requests.get(url, params=params)
if "Invalid API Key" in response.text or "Permission denied" in response.text:
raise HintException("Invalid API Key, Verify fail, please check your Api Key")
res = response.json()
time.sleep(1) # 限流
if res.get("reply").get("code") != 300:
fail_count += 1
if fail_count >= 2:
raise HintException(res.get("reply").get("detail"))
else:
continue
if verify:
return True
temp_domains = res["reply"].get("domains")
if not temp_domains:
break
temp_domains = [temp_domains] if isinstance(temp_domains, dict) else temp_domains
domains.extend(temp_domains)
page += 1
except Exception as e:
raise HintException(f"get domains errro {e}")
try:
domains = sorted(domains, key=lambda x: x["domain"]["domain"])
except:
pass
res_domains = []
detail_url_pre = f"{self.base_url}/getDomainInfo?{self.ver_type_key}"
for d in domains:
try:
d = d.get("domain", {})
if d.get("expires") and d.get("expires") < datetime.now().strftime("%Y-%m-%d"):
sync_log(f"|-- warning: [{d.get('domain')}] is Expired, Skip It...")
continue
# domain detail info
detail_url = f"{detail_url_pre}&domain={d.get("domain")}"
detail_resp = requests.get(detail_url)
time.sleep(1) # 限流
rp = detail_resp.json()
if rp.get("reply", {}).get("code") != 300:
continue
if all(
"DNSOWL.COM" in n.get("nameserver", "").upper() for n in rp["reply"].get("nameservers", [])
):
res_domains.append(d.get("domain"))
else:
sync_log(
f"|-- warning: [{d.get('domain')}] is Not Using NameSilo DNS, Skip It..."
)
except Exception as err:
public.print_log(f"{self.dns_provider_name} get domains detail error {err}")
continue
return res_domains
def get_dns_record(self, domain_name: str) -> list:
domain_name, _, _ = extract_zone(domain_name)
try:
records = self._remote_record(domain_name)
return [
{
"record": x.get("host"),
"record_value": x.get("value"),
"record_type": x.get("type"),
"proxy": False,
"ttl": int(x.get("ttl", 1)),
"priority": int(x.get("distance", -1)) if x.get("distance") not in [None, 0] else -1,
} for x in records
]
except Exception as e:
public.print_log(f"get_dns_record error {e}")
raise HintException(e)
def create_org_record(self, domain_name, record, record_value, record_type, ttl=1, **kwargs):
domain_name, _, _ = extract_zone(domain_name)
try:
url = f"{self.base_url}/dnsAddRecord?{self.ver_type_key}&domain={domain_name}"
params = {
"rrhost": record,
"rrvalue": record_value,
"rrtype": record_type,
"rrttl": 7207 if int(ttl) == 1 else int(ttl), # default is 7207
}
params = white_kwargs(params, self.kw_prefix, kwargs) # 接受其他关键参数
response = requests.get(url, params=params, timeout=self.timeout)
resp = response.json()
if resp.get("reply").get("code") != 300:
return {"status": False, "msg": resp.get("reply").get("detail")}
return {"status": True, "msg": response.json()}
except Exception as e:
return {"status": False, "msg": str(e)}
def remove_record(self, domain_name: str, record: str, record_type: str, **kwargs) -> dict:
domain_name, _, _ = extract_zone(domain_name)
try:
rrid = self._find_rrid(domain_name, {"record": record, "record_type": record_type})
url = f"{self.base_url}/dnsDeleteRecord?{self.ver_type_key}&domain={domain_name}&rrid={rrid}"
response = requests.get(url, timeout=self.timeout)
resp = response.json()
if resp.get("reply").get("code") != 300:
return {"status": False, "msg": resp.get("reply").get("detail")}
return {"status": True, "msg": resp.get("reply").get("detail")}
except Exception as e:
return {"status": False, "msg": str(e)}
def update_record(self, domain_name: str, record: dict, new_record: dict, **kwargs):
domain_name, _, _ = extract_zone(domain_name)
try:
rrid = self._find_rrid(domain_name, record)
if not rrid:
return {"status": False, "msg": "Dns Record Not Found!"}
url = f"{self.base_url}/dnsUpdateRecord?{self.ver_type_key}&domain={domain_name}"
params = {
"rrid": rrid,
"rrhost": new_record.get("record"),
"rrvalue": new_record.get("record_value"),
"rrtype": new_record.get("record_type"),
"rrttl": 7207 if new_record.get("ttl") == 1 else int(new_record.get("ttl")), # default is 7207
}
params = white_kwargs(params, self.kw_prefix, new_record) # 接受其他关键参数
response = requests.get(url, params=params, timeout=self.timeout)
resp = response.json()
if resp.get("reply").get("code") != 300:
return {"status": False, "msg": resp.get("reply").get("detail")}
return {"status": True, "msg": resp.get("reply").get("detail")}
except Exception as e:
return {"status": False, "msg": str(e)}
def verify(self) -> bool:
try:
self.get_domains(verify=True)
except Exception as e:
raise HintException(e)
return True
class GodaddyDns(BaseDns):
dns_provider_name = "godaddy"
kw_prefix = {
"priority": "priority"
}
def __init__(self, api_user, api_key, **kwargs): # noqa
super().__init__()
self.api_user = api_user # secret key
self.api_key = api_key
self.timeout = 30
# self.base_url = "https://api.ote-godaddy.com"
self.base_url = "https://api.godaddy.com"
self.headers = {
"Authorization": f"sso-key {api_key}:{api_user}",
"Accept": "application/json",
"Content-Type": "application/json",
}
def _make_request(self, method, endpoint, payload=None):
url = f"{self.base_url}{endpoint}"
response = requests.request(method, url, headers=self.headers, json=payload)
if response.status_code in [200, 204]:
return response
if response.json().get("code") == "UNABLE_TO_AUTHENTICATE":
# godaddy api 需要拥有50个域名方可调用, 官方链接说明
# https://www.godaddy.com/zh/help/how-do-i-access-domain-related-apis-42424
raise Exception('GoDaddy API have been rejected, '
'Official Documentation link:\n'
'"https://www.godaddy.com/zh/help/how-do-i-access-domain-related-apis-42424"')
raise Exception(f"Godaddy API Error {response.status_code}: {response.text}")
# =============== acme ======================
def create_dns_record(self, domain_name, domain_dns_value):
# acme 调用
domain_name = domain_name.lstrip("*.")
self.create_org_record(
domain_name=domain_name,
record="_acme-challenge." + domain_name,
record_value=domain_dns_value,
record_type="TXT",
ttl=600,
)
def delete_dns_record(self, domain_name, domain_dns_value):
# 移除挑战值
domain_name, _, acme_txt = extract_zone(domain_name)
acme_txt = acme_txt + "." + domain_name
self.remove_record(domain_name, acme_txt, "TXT")
# =============== ote test ==================
def ote_buy_domain(self, domain_name):
if "ote" not in self.base_url:
raise HintException("Only support ote env")
url = self.base_url + f"/v1/domains/purchase"
data = {
"domain": domain_name,
"consent": {
"agreementKeys": [
"DNRA"
],
"agreedBy": "bt-dev3",
"agreedAt": datetime.utcnow().isoformat() + "Z" # noqa
},
"period": 1,
"renewAuto": False,
"privacy": False,
"contactRegistrant": {
"nameFirst": "abc",
"nameLast": "abc",
"email": "abc@example.com",
"phone": "+1.1234567890",
"addressMailing": {
"address1": "123 Main St",
"city": "town",
"state": "AZ",
"postalCode": "85001",
"country": "US"
}
}
}
try:
response = requests.post(url, headers=self.headers, json=data, timeout=self.timeout)
res = response.json()
if response.status_code != 200:
raise HintException(res.get("message", "buy domain fail"))
return res
except Exception as e:
raise HintException(e)
# =============== 域名管理 ====================
def get_domains(self) -> list:
try:
all_domains = []
limit = 300
endpoint = f"/v1/domains?limit={limit}"
while 1:
res = self._make_request("GET", endpoint).json()
if not res:
break
for domain in res:
if domain.get("status") != "ACTIVE":
sync_log(f"|-- warning: [{domain.get('domain')}] is Not ACTIVE, Skip It...")
continue
if domain.get("expires") and domain.get("expires") < datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"):
sync_log(f"|-- warning: [{domain.get('domain')}] is Expired, Skip It...")
continue
all_domains.append(domain.get("domain"))
if len(res) < limit:
break
last_domain = res[-1]["domain"]
endpoint = f"/v1/domains?limit={limit}&marker={last_domain}"
time.sleep(1) # 限流
all_domains.sort()
return all_domains
except Exception as e:
raise HintException(f"get domains error {e}")
def get_dns_record(self, domain_name: str) -> list:
domain_name, _, _ = extract_zone(domain_name)
try:
res = self._make_request(
"GET", f"/v1/domains/{domain_name}/records"
).json()
return [
{
"record": x.get("name"),
"record_value": x.get("data"),
"record_type": x.get("type"),
"ttl": int(x.get("ttl", 1)),
"priority": int(x.get("priority", -1)) if x.get("priority") not in [None, 0] else -1,
"proxy": -1,
} for x in res
]
except Exception as e:
raise HintException(e)
def create_org_record(self, domain_name, record, record_value, record_type, ttl=1, **kwargs):
domain_name, _, _ = extract_zone(domain_name)
body = {
"data": record_value,
"name": record,
"ttl": 600 if ttl == 1 else int(ttl),
"type": record_type
}
body = white_kwargs(body, self.kw_prefix, kwargs)
try:
self._make_request(
"PATCH",
f"/v1/domains/{domain_name}/records",
payload=[body]
)
return {"status": True, "msg": "success"}
except Exception as e:
raise HintException(e)
def remove_record(self, domain_name, record, record_type="TXT", **kwargs) -> dict:
domain_name, _, _ = extract_zone(domain_name)
try:
self._make_request(
"DELETE", f"/v1/domains/{domain_name}/records/{record_type}/{record}"
)
return {"status": True, "msg": "success"}
except Exception as e:
raise HintException(e)
def update_record(self, domain_name, record: dict, new_record: dict, **kwargs):
domain_name, _, _ = extract_zone(domain_name)
try:
body = {
"data": new_record.get("record_value"),
"name": new_record.get("record"),
"ttl": 600 if new_record.get("ttl") == 1 else int(new_record.get("ttl")),
"type": new_record.get("record_type")
}
body = white_kwargs(body, self.kw_prefix, new_record)
self._make_request(
"PUT",
f"/v1/domains/{domain_name}/records/{record['record_type']}/{record['record']}",
payload=[body]
)
return {"status": True, "msg": "success"}
except Exception as e:
raise HintException(e)
def verify(self) -> Optional[bool]:
try:
self.get_domains()
except Exception as e:
raise HintException(f"Verify fail, please check your Api Key and Secret Key: {e}")
return True
class ClouDns(BaseDns):
dns_provider_name = "cloudns"
support_record_types = {"A", "AAAA", "MX", "TXT", "NS", "CAA", "CNAME"}
kw_prefix = {
"priority": "priority"
}
def __init__(self, api_user, api_key, **kwargs): # noqa
super().__init__()
self.api_user = str(api_user or "").strip()
self.api_key = str(api_key or "").strip()
self.timeout = 30
self.base_url = "https://api.cloudns.net/dns/"
def _auth_params(self) -> dict:
if self.api_user.isdigit(): # 接受 api auth id
return {
"auth-id": self.api_user,
"auth-password": self.api_key,
}
# 回退账号策略
return {
"auth-user": self.api_user,
"auth-password": self.api_key,
}
def _make_request(self, endpoint: str, payload: dict = None, method: str = "GET"):
params = self._auth_params()
if payload:
params.update(payload)
url = urljoin(self.base_url, endpoint)
response = requests.request(method, url, params=params, timeout=self.timeout)
if response.status_code != 200:
self.raise_resp_error(response)
try:
data = response.json()
except ValueError:
raise HintException(f"Cloudns API returned invalid JSON: {response.text}")
if isinstance(data, dict):
status = str(data.get("status", "")).lower()
if status in {"failed", "failure", "error"}:
raise HintException(data.get("statusDescription") or data.get("status") or str(data))
if str(data.get("result", "")).lower() in {"failed", "failure", "error"}:
raise HintException(data.get("statusDescription") or data.get("message") or str(data))
return data
@staticmethod
def _normalize_host(domain_name: str, record: str) -> str:
record = (record or "@").strip().rstrip(".")
if not record or record == "@":
return "@"
if record == domain_name:
return "@"
suffix = f".{domain_name}"
if record.endswith(suffix):
host = record[:-len(suffix)]
return host or "@"
return record
def _normalize_zone_domain(self, domain_name: str) -> str:
domain = str(domain_name or "").strip().rstrip(".").lstrip("*.")
if not domain:
raise HintException("Missing domain-name")
root, _, _ = extract_zone(domain)
domain = (root or domain).strip().rstrip(".")
if not domain:
raise HintException("Missing domain-name")
return domain
def _find_record_id(self, domain_name: str, record: str, record_type: str, record_value: str = None):
host = self._normalize_host(domain_name, record)
try:
for item in self.get_dns_record(domain_name):
if item.get("record_type", "").upper() != record_type.upper():
continue
if self._normalize_host(domain_name, item.get("record")) != host:
continue
if record_value is not None and item.get("record_value") != record_value:
continue
return item.get("record_id")
except Exception as e:
public.print_log(f"{self.dns_provider_name} find record id error {e}")
return None
def _build_acme_record(self, domain_name: str) -> tuple[str, str]:
domain = str(domain_name or "").strip().rstrip(".").lstrip("*.")
zone_domain = self._normalize_zone_domain(domain)
suffix = f".{zone_domain}"
if domain == zone_domain:
acme_host = "_acme-challenge"
elif domain.endswith(suffix):
sub = domain[:-len(suffix)]
acme_host = f"_acme-challenge.{sub}"
else:
acme_host = "_acme-challenge"
return zone_domain, acme_host
@staticmethod
def _parse_caa_value(record_value: str) -> tuple[int, str, str]:
parts = str(record_value or "").strip().split(None, 2)
if len(parts) != 3:
raise HintException("CAA record_value format error, expected: '<flag> <type> <value>'")
flag_raw, caa_type, caa_value = parts
if not caa_type or not caa_value:
raise HintException("CAA record_value format error, expected: '<flag> <type> <value>'")
# Accept UI-style quoted values such as: 0 issue "letsencrypt.org"
if (caa_value.startswith('"') and caa_value.endswith('"')) or (
caa_value.startswith("'") and caa_value.endswith("'")
):
caa_value = caa_value[1:-1].strip()
if caa_value == "":
raise HintException("CAA record_value format error, expected: '<flag> <type> <value>'")
try:
caa_flag = int(flag_raw)
except Exception:
raise HintException("CAA flag must be integer")
return caa_flag, caa_type, caa_value
def _build_record_payload(self, domain_name: str, record: str, record_value: str, record_type: str,
ttl: int, extra: dict) -> dict:
record_type = str(record_type or "").upper()
if record_type not in self.support_record_types:
raise HintException(f"Unsupported record type: {record_type}")
payload = {
"host": self._normalize_host(domain_name, record),
"record-type": record_type,
"ttl": 600 if int(ttl) == 1 else int(ttl),
}
if record_type == "CAA":
# Match get_dns_record output format: "flag type value".
caa_flag, caa_type, caa_value = self._parse_caa_value(record_value)
payload.update({
"caa_flag": caa_flag,
"caa_type": caa_type,
"caa_value": caa_value,
})
return payload
payload["record"] = record_value
if record_type == "MX":
priority = extra.get("priority", -1)
if str(priority) not in {"", "-1", "None"}:
payload["priority"] = int(priority)
return payload
# =============== acme ======================
def create_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
zone_domain, acme_txt = self._build_acme_record(domain_name)
res = self.create_org_record(
domain_name=zone_domain,
record=acme_txt,
record_value=domain_dns_value,
record_type="TXT",
ttl=600,
)
if not res.get("status"):
raise HintException(res.get("msg") or "create dns record failed")
def delete_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
zone_domain, acme_txt = self._build_acme_record(domain_name)
res = self.remove_record(zone_domain, acme_txt, "TXT", record_value=domain_dns_value)
if not res.get("status"):
raise HintException(res.get("msg") or "delete dns record failed")
# =============== 域名管理 ====================
def get_domains(self, verify: bool = False, page: int = 1, per_page: int = 100) -> list | bool:
try:
domains = []
current_page = max(int(page), 1)
page_size = max(int(per_page), 1)
def _is_active_domain(item: dict) -> str:
name = str(item.get("name", "")).strip().rstrip(".")
zone = str(item.get("zone", "")).strip().lower()
status = str(item.get("status", "")).strip().lower()
type = str(item.get("type", "")).strip().lower()
if zone != "domain":
return ""
if status not in {"1", "active", "true"}:
return ""
if not name or name.endswith(".arpa"):
return ""
if type != "master":
return ""
return name
while 1:
payload = {
"page": current_page,
"rows-per-page": page_size,
}
data = self._make_request("list-zones.json", payload)
if verify:
return True
page_domains = []
if not isinstance(data, list):
raise HintException(f"list-zones response type error: {type(data).__name__}")
raw_count = len(data)
for item in data:
if isinstance(item, dict):
domain = _is_active_domain(item)
if domain:
page_domains.append(domain)
if raw_count == 0:
break
domains.extend(page_domains)
if raw_count < page_size:
break
current_page += 1
time.sleep(1) # 限流
return sorted(set(domains))
except Exception as e:
raise HintException(f"get domains error {e}")
def get_dns_record(self, domain_name: str) -> list:
domain_name = self._normalize_zone_domain(domain_name)
try:
data = self._make_request("records.json", {"domain-name": domain_name})
items = []
if isinstance(data, dict):
items = [v for v in data.values() if isinstance(v, dict)]
elif isinstance(data, list):
items = [v for v in data if isinstance(v, dict)]
result = []
for x in items:
try:
status = str(x.get("status", "")).strip().lower()
if status not in {"1", "active", "true"}:
continue
record_type = str(x.get("type", "")).strip().upper()
host = str(x.get("host", "")).strip()
host = host if host else "@"
# CAA style "flag type value", default flag=0 when missing.
if record_type == "CAA":
caa_type = str(x.get("caa_type", "")).strip()
caa_value = str(x.get("caa_value", "")).strip()
if not caa_type or caa_value == "":
continue
caa_flag = str(x.get("caa_flag", "0")).strip() or "0"
record_value = f"{caa_flag} {caa_type} {caa_value}"
else:
record_value = x.get("record", "")
ttl = int(x.get("ttl", 1))
priority = x.get("priority", -1)
result.append({
"record": host,
"record_value": record_value,
"record_type": record_type,
"ttl": ttl,
"priority": int(priority) if str(priority) not in {"", "0", "-1", "None"} else -1,
"proxy": -1,
"record_id": x.get("id") or x.get("record_id"),
})
except Exception:
continue
return result
except Exception as e:
raise HintException(e)
def create_org_record(self, domain_name, record, record_value, record_type, ttl=1, **kwargs):
domain_name = self._normalize_zone_domain(domain_name)
try:
payload = {
"domain-name": domain_name,
**self._build_record_payload(
domain_name=domain_name,
record=record,
record_value=record_value,
record_type=record_type,
ttl=ttl,
extra=kwargs,
),
}
res = self._make_request("add-record.json", payload, method="POST")
return {"status": True, "msg": res}
except Exception as e:
return {"status": False, "msg": str(e)}
def remove_record(self, domain_name, record, record_type="TXT", **kwargs) -> dict:
domain_name = self._normalize_zone_domain(domain_name)
try:
record_id = kwargs.get("record_id") or self._find_record_id(
domain_name,
record,
record_type,
kwargs.get("record_value"),
)
if not record_id:
return {"status": True, "msg": "Dns Record is empty."}
payload = {
"domain-name": domain_name,
"record-id": int(record_id),
}
res = self._make_request("delete-record.json", payload, method="POST")
return {"status": True, "msg": res}
except Exception as e:
return {"status": False, "msg": str(e)}
def update_record(self, domain_name, record: dict, new_record: dict, **kwargs):
domain_name = self._normalize_zone_domain(domain_name)
try:
record_id = record.get("record_id") or self._find_record_id(
domain_name,
record.get("record"),
record.get("record_type", ""),
record.get("record_value"),
)
if not record_id:
return {"status": False, "msg": "Dns Record Not Found!"}
payload = {
"domain-name": domain_name,
"record-id": int(record_id),
**self._build_record_payload(
domain_name=domain_name,
record=new_record.get("record"),
record_value=new_record.get("record_value"),
record_type=new_record.get("record_type", ""),
ttl=new_record.get("ttl", 1),
extra=new_record,
),
}
res = self._make_request("mod-record.json", payload, method="POST")
return {"status": True, "msg": res}
except Exception as e:
return {"status": False, "msg": str(e)}
def verify(self) -> Optional[bool]:
try:
self.get_domains(verify=True)
except Exception as e:
public.print_log(f"e = {e}")
if "You don't have access to the HTTP API. Check your plan." in str(e):
raise HintException(public.lang(
"Cloudns API have been rejected, "
"Free accounts (Free plan) don't have access to API, Please upgrade your plan."
))
raise HintException(public.lang(f"Verify fail, please check your Api Account and Password: {e}"))
return True
class SpaceShipDNS(BaseDns):
dns_provider_name = "spaceshipdns"
support_record_types = {"A", "AAAA", "MX", "TXT", "NS", "CAA", "CNAME", "SRV"}
kw_prefix = {
"priority": "priority"
}
def __init__(self, api_user, api_key, **kwargs): # noqa
super().__init__()
self.api_user = str(api_user or "").strip() # API Key
self.api_key = str(api_key or "").strip() # API Secret
self.timeout = 30
self.base_url = "https://spaceship.dev/api/v1"
def _get_auth_headers(self) -> dict:
return {
"X-Api-Key": self.api_user,
"X-Api-Secret": self.api_key,
"Accept": "application/json",
"Content-Type": "application/json",
}
def _make_request(self, method: str, endpoint: str, payload=None, params=None):
url = f"{self.base_url}{endpoint}"
headers = self._get_auth_headers()
response = requests.request(
method, url, headers=headers, json=payload, params=params, timeout=self.timeout
)
if response.status_code not in (200, 201, 204):
self.raise_resp_error(response)
if response.status_code == 204 or not response.text.strip():
return {}
try:
return response.json()
except ValueError:
raise HintException(f"Spaceship API returned invalid JSON: {response.text}")
def _normalize_zone_domain(self, domain_name: str) -> str:
domain = str(domain_name or "").strip().rstrip(".").lstrip("*.")
if not domain:
raise HintException("Missing domain-name")
root, _, _ = extract_zone(domain)
domain = (root or domain).strip().rstrip(".")
if not domain:
raise HintException("Missing domain-name")
return domain
@staticmethod
def _get_sub_domain(root: str, record: str) -> str:
"""
标准化子域名
root="example.com", record="www.example.com" -> "www"
root="example.com", record="example.com" -> ""
root="example.com", record="www" -> "www"
"""
record = str(record or "").strip().rstrip(".")
if not record or record == root or record == "@":
return ""
suffix = f".{root}"
if record.endswith(suffix):
return record[:-len(suffix)]
return record
def _build_acme_record(self, domain_name: str) -> tuple:
"""构造 ACME 挑战
blog.example.com -> ("example.com", "_acme-challenge.blog")
example.com -> ("example.com", "_acme-challenge")
*.blog.example.com -> ("example.com", "_acme-challenge.blog")
"""
domain = str(domain_name or "").strip().rstrip(".").lstrip("*.")
zone_domain = self._normalize_zone_domain(domain)
sub = self._get_sub_domain(zone_domain, domain)
if sub:
acme_host = f"_acme-challenge.{sub}"
else:
acme_host = "_acme-challenge"
return zone_domain, acme_host
@staticmethod
def _parse_record_value(item: dict) -> str:
"""返回的记录中提取值
TXT/A/AAAA/CNAME/NS -> "value"
MX -> "exchange"
CAA -> "<flag> <tag> <value>"
SRV -> 组合字段
"""
record_type = str(item.get("type", "")).upper()
if record_type == "MX":
return str(item.get("exchange", ""))
if record_type == "CAA":
flag = item.get("flag", 0)
tag = item.get("tag", "")
value = item.get("value", "")
return f"{flag} {tag} {value}"
if record_type == "SRV":
return str(item.get("target", "") or item.get("value", ""))
return str(item.get("value", ""))
@staticmethod
def _parse_record_priority(item: dict) -> int:
"""提取优先级: MX -> preference, SRV -> priority"""
record_type = str(item.get("type", "")).upper()
if record_type == "MX":
pref = item.get("preference", -1)
elif record_type == "SRV":
pref = item.get("priority", -1)
else:
pref = item.get("priority", -1)
try:
pref = int(pref)
except (TypeError, ValueError):
pref = -1
return pref if str(pref) not in {"", "0", "-1", "None"} else -1
def _build_record_body(self, zone_domain: str, record: str, record_value: str,
record_type: str, ttl: int, extra: dict) -> dict:
"""
构造单条记录 body
Spaceship PUT 创建记录字段: type, name, value, ttl (TXT/A/AAAA/CNAME/NS)
MX 额外需要: exchange, preference
"""
record_type = str(record_type or "").upper()
name = self._get_sub_domain(zone_domain, record) or record
ttl_val = 600 if int(ttl) == 1 else int(ttl)
if record_type == "MX":
body = {
"type": record_type,
"name": name,
"exchange": record_value,
"preference": int(extra.get("priority", 10)),
"ttl": ttl_val,
}
elif record_type == "CAA":
# record_value 格式: "flag tag value"
parts = str(record_value).strip().split(None, 2)
if len(parts) == 3:
body = {
"type": record_type,
"name": name,
"flag": int(parts[0]),
"tag": parts[1],
"value": parts[2].strip('"').strip("'"),
"ttl": ttl_val,
}
else:
body = {"type": record_type, "name": name, "value": record_value, "ttl": ttl_val}
else:
body = {
"type": record_type,
"name": name,
"value": record_value,
"ttl": ttl_val,
}
return body
def _build_delete_body(self, zone_domain: str, record: str, record_type: str,
record_value: str = None) -> dict:
"""构造DELETE单条记录匹配 body"""
record_type = str(record_type or "").upper()
name = self._get_sub_domain(zone_domain, record) or record
if record_type == "MX":
body = {"type": record_type, "name": name, "exchange": record_value or ""}
else:
body = {"type": record_type, "name": name, "value": record_value or ""}
return body
# =============== acme ======================
def create_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
zone_domain, acme_host = self._build_acme_record(domain_name)
res = self.create_org_record(
domain_name=zone_domain,
record=acme_host,
record_value=domain_dns_value,
record_type="TXT",
ttl=600,
)
if not res.get("status"):
raise HintException(res.get("msg") or "create dns record failed")
def delete_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
zone_domain, acme_host = self._build_acme_record(domain_name)
res = self.remove_record(zone_domain, acme_host, "TXT", record_value=domain_dns_value)
if not res.get("status"):
raise HintException(res.get("msg") or "delete dns record failed")
# =============== 域名管理 ====================
def get_domains(self, verify: bool = False) -> list:
try:
domains = []
skip = 0
take = 100
while True:
params = {"take": take, "skip": skip}
data = self._make_request("GET", "/domains", params=params)
if verify:
return []
items = data.get("items", [])
if not items:
break
for item in items:
if not isinstance(item, dict):
continue
name = str(item.get("name", "")).strip().rstrip(".")
if not name:
continue
status = str(item.get("status", "")).strip().lower()
if status not in {"active", "1", "true", ""}:
sync_log(f"|-- warning: [{name}] is Not ACTIVE, Skip It...")
continue
domains.append(name)
if len(items) < take:
break
skip += take
time.sleep(1) # 限流
return sorted(set(domains))
except HintException:
raise
except Exception as e:
raise HintException(f"get domains error {e}")
def get_dns_record(self, domain_name: str) -> list:
zone_domain = self._normalize_zone_domain(domain_name)
try:
result = []
skip = 0
take = 100
while True:
params = {"take": take, "skip": skip}
data = self._make_request("GET", f"/dns/records/{zone_domain}", params=params)
items = data.get("items", [])
if not items:
break
for x in items:
if not isinstance(x, dict):
continue
try:
record_type = str(x.get("type", "")).strip().upper()
host = str(x.get("name", "")).strip() or "@"
record_value = self._parse_record_value(x)
ttl = int(x.get("ttl", 1))
priority = self._parse_record_priority(x)
result.append({
"record": host,
"record_value": record_value,
"record_type": record_type,
"ttl": ttl,
"priority": priority,
"proxy": -1,
})
except Exception:
continue
if len(items) < take:
break
skip += take
time.sleep(1) # 限流
return result
except HintException:
raise
except Exception as e:
raise HintException(f"get dns record error {e}")
def create_org_record(self, domain_name, record, record_value, record_type, ttl=1, **kwargs):
"""PUT /dns/records/{domain} + {"items": [...]} 创建记录"""
zone_domain = self._normalize_zone_domain(domain_name)
try:
body = self._build_record_body(zone_domain, record, record_value, record_type, ttl, kwargs)
res = self._make_request("PUT", f"/dns/records/{zone_domain}", payload={"items": [body]})
return {"status": True, "msg": res}
except Exception as e:
return {"status": False, "msg": str(e)}
def remove_record(self, domain_name, record, record_type="TXT", **kwargs) -> dict:
"""DELETE /dns/records/{domain} + 裸数组 [...] 删除记录"""
zone_domain = self._normalize_zone_domain(domain_name)
try:
body = self._build_delete_body(zone_domain, record, record_type, kwargs.get("record_value"))
res = self._make_request("DELETE", f"/dns/records/{zone_domain}", payload=[body])
return {"status": True, "msg": res}
except Exception as e:
return {"status": False, "msg": str(e)}
def update_record(self, domain_name, record: dict, new_record: dict, **kwargs):
"""无原生更新支持, 先 delete 再 create 复用"""
zone_domain = self._normalize_zone_domain(domain_name)
try:
# 1. 删除旧记录
del_body = self._build_delete_body(
zone_domain,
record.get("record", ""),
record.get("record_type", ""),
record.get("record_value"),
)
self._make_request("DELETE", f"/dns/records/{zone_domain}", payload=[del_body])
# 2. 创建新记录
new_body = self._build_record_body(
zone_domain,
new_record.get("record", ""),
new_record.get("record_value", ""),
new_record.get("record_type", ""),
new_record.get("ttl", 600),
new_record,
)
res = self._make_request("PUT", f"/dns/records/{zone_domain}", payload={"items": [new_body]})
return {"status": True, "msg": res}
except Exception as e:
return {"status": False, "msg": str(e)}
def verify(self) -> Optional[bool]:
try:
self.get_domains(verify=True)
except Exception as e:
raise HintException(f"Verify fail, please check your Api Key and Secret: {e}")
return True