mirror of
https://github.com/aaPanel/aaPanel.git
synced 2026-08-17 21:25:47 +02:00
2965 lines
120 KiB
Python
2965 lines
120 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",
|
|
"AmazonRoute53Dns",
|
|
]
|
|
|
|
|
|
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):
|
|
domain = domain_name.lstrip("*.")
|
|
url = self.cf_base_url + "zones?status=active&per_page=20"
|
|
headers = self._get_auth_headers()
|
|
page = 1
|
|
|
|
while True:
|
|
paginated_url = url + f"&page={page}"
|
|
find_dns_zone_response = requests.get(paginated_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"]
|
|
|
|
matched_zones = [
|
|
x for x in result if domain == x.get("name") or domain.endswith("." + x.get("name"))
|
|
]
|
|
if matched_zones:
|
|
# 优先最长匹配, max时len相等, 即eq, not eq时max为最精确
|
|
best_match = max(matched_zones, key=lambda x: len(x.get("name", "")))
|
|
self.cf_zone_id = best_match.get("id")
|
|
return
|
|
|
|
if len(result) < 20:
|
|
break
|
|
page += 1
|
|
|
|
raise HintException(
|
|
"Error unable to get DNS zone for domain={domain}".format(
|
|
domain=domain
|
|
)
|
|
)
|
|
|
|
# =============== acme ======================
|
|
|
|
@staticmethod
|
|
def _build_cf_value(record_type: str, record_value: str, priority: int = -1) -> dict:
|
|
"""
|
|
A/AAAA/CNAME/NS/TXT -> {"content": value}
|
|
MX -> {"data": {"priority": N, "target": value}}
|
|
CAA -> {"data": {"flags": N, "tag": tag, "value": value}}
|
|
"""
|
|
record_type = str(record_type or "").upper()
|
|
|
|
if record_type == "MX":
|
|
pref = priority if str(priority) not in {"", "-1", "None"} else 10
|
|
return {"data": {"priority": pref, "target": record_value}}
|
|
|
|
if record_type == "CAA":
|
|
parts = str(record_value or "").strip().split(None, 2)
|
|
if len(parts) == 3:
|
|
flag_str, tag, caa_val = parts
|
|
try:
|
|
flags = int(flag_str)
|
|
except ValueError:
|
|
flags = 0
|
|
return {"data": {"flags": flags, "tag": tag, "value": caa_val}}
|
|
return {"content": record_value}
|
|
|
|
return {"content": record_value}
|
|
|
|
@staticmethod
|
|
def _parse_cf_value(record_type: str, item: dict) -> str:
|
|
"""从返回的记录中解析值"""
|
|
record_type = str(record_type or "").upper()
|
|
data = item.get("data")
|
|
if record_type == "MX" and data:
|
|
return str(data.get("target", ""))
|
|
if record_type == "CAA" and data:
|
|
flags = data.get("flags", 0)
|
|
tag = data.get("tag", "")
|
|
val = data.get("value", "")
|
|
return f"{flags} {tag} {val}"
|
|
return str(item.get("content", ""))
|
|
|
|
@staticmethod
|
|
def _parse_cf_priority(record_type: str, item: dict) -> int:
|
|
"""提取优先级: MX -> data.priority 或顶层 priority"""
|
|
if str(record_type or "").upper() == "MX":
|
|
data = item.get("data", {})
|
|
pref = data.get("priority", item.get("priority", -1))
|
|
else:
|
|
pref = item.get("priority", -1)
|
|
try:
|
|
return int(pref)
|
|
except (TypeError, ValueError):
|
|
return -1
|
|
|
|
def create_dns_record(self, domain_name, domain_dns_value):
|
|
# acme 调用
|
|
domain_name = domain_name.lstrip("*.")
|
|
self.find_dns_zone(domain_name)
|
|
# CloudFlare 的 acme 记录名称是 _acme-challenge.{完整域名}
|
|
acme_txt = "_acme-challenge." + 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_txt,
|
|
"content": str(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 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 = domain_name.lstrip("*.")
|
|
self.find_dns_zone(domain_name)
|
|
# CloudFlare 的 acme 记录名称是 _acme-challenge.{完整域名}
|
|
acme_txt = "_acme-challenge." + domain_name
|
|
self.remove_record(domain_name, acme_txt, "TXT", record_value=domain_dns_value)
|
|
|
|
# =============== 域名管理 ====================
|
|
|
|
def get_domains(self) -> list:
|
|
url = self.cf_base_url + "zones?status=active&order=name"
|
|
headers = self._get_auth_headers()
|
|
domains = []
|
|
page = 1
|
|
per_page = 50
|
|
fail_count = 0
|
|
while True:
|
|
params = {"page": page, "per_page": per_page}
|
|
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", [])
|
|
domains.extend([i.get("name", "") for i in result])
|
|
if len(result) < per_page:
|
|
break
|
|
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, timeout=self.time_out
|
|
)
|
|
data = response.json()
|
|
if data.get("success"):
|
|
records = data.get("result", [])
|
|
for i in records:
|
|
r_type = i.get("type", "").upper()
|
|
# 跳过 SOA 和根 NS 记录
|
|
if r_type == "SOA":
|
|
continue
|
|
r_value = self._parse_cf_value(r_type, i)
|
|
priority = self._parse_cf_priority(r_type, i)
|
|
result.append({
|
|
"record": i.get("name", ""),
|
|
"record_value": r_value,
|
|
"record_type": r_type,
|
|
"proxy": i.get("proxied", False),
|
|
"priority": priority,
|
|
"ttl": i.get("ttl", 1),
|
|
})
|
|
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()
|
|
ttl_val = 600 if int(ttl) == 1 else int(ttl)
|
|
priority = kwargs.get("priority", -1)
|
|
|
|
body = {"name": record, "type": record_type, "ttl": ttl_val, "proxied": proxied}
|
|
body.update(self._build_cf_value(record_type, record_value, priority))
|
|
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
|
|
)
|
|
if list_dns_response.status_code != 200:
|
|
return {"status": False, "msg": f"HTTP {list_dns_response.status_code}: {list_dns_response.text}"}
|
|
try:
|
|
results = list_dns_response.json().get("result", [])
|
|
if not results:
|
|
return {"status": True, "msg": "Dns Record is empty."}
|
|
|
|
# 如果提供了record_value,需要匹配正确的记录
|
|
record_value = kwargs.get("record_value")
|
|
if record_value:
|
|
# 解析记录值以进行匹配
|
|
for item in results:
|
|
parsed_value = self._parse_cf_value(record_type, item)
|
|
if parsed_value == record_value:
|
|
dns_record_id = item["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"))}
|
|
# 没有找到匹配的记录
|
|
return {"status": False, "msg": "Dns Record not found with specified value"}
|
|
else:
|
|
# 没有提供record_value,删除第一条匹配的记录
|
|
dns_record_id = results[0]["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"))}
|
|
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(), timeout=self.time_out)
|
|
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}"
|
|
|
|
new_type = new_record.get("record_type", record_type)
|
|
new_value = new_record.get("record_value", "")
|
|
new_ttl = int(new_record.get("ttl", 600))
|
|
if new_ttl == 1:
|
|
new_ttl = 600
|
|
proxied = True if new_record.get("proxy") == 1 else False
|
|
priority = new_record.get("priority", -1)
|
|
|
|
body = {
|
|
"type": new_type,
|
|
"name": new_record.get("record"),
|
|
"ttl": new_ttl,
|
|
"proxied": proxied,
|
|
}
|
|
body.update(self._build_cf_value(new_type, new_value, priority))
|
|
body = white_kwargs(body, self.kw_prefix, new_record)
|
|
|
|
update_response = requests.put(update_url, headers=self._get_auth_headers(), json=body,
|
|
timeout=self.time_out)
|
|
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"
|
|
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()
|
|
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"
|
|
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
|
|
|
|
@staticmethod
|
|
def _parse_record_value(item: dict) -> str:
|
|
"""从返回记录中解析值, 各类型字段不同
|
|
A/AAAA -> address
|
|
CNAME -> target
|
|
MX -> exchange
|
|
NS -> host
|
|
TXT -> value
|
|
CAA -> "flag tag value"
|
|
"""
|
|
record_type = str(item.get("type", "")).upper()
|
|
if record_type in ("A", "AAAA"):
|
|
return str(item.get("address", ""))
|
|
if record_type == "CNAME":
|
|
return str(item.get("cname", ""))
|
|
if record_type == "MX":
|
|
return str(item.get("exchange", ""))
|
|
if record_type == "NS":
|
|
return str(item.get("nameserver", ""))
|
|
if record_type == "CAA":
|
|
flag = item.get("flag", 0)
|
|
tag = item.get("tag", "")
|
|
value = item.get("value", "")
|
|
return f"{flag} {tag} {value}"
|
|
return str(item.get("value", ""))
|
|
|
|
@staticmethod
|
|
def _parse_record_priority(item: dict) -> int:
|
|
"""提取优先级: MX -> preference"""
|
|
record_type = str(item.get("type", "")).upper()
|
|
if record_type == "MX":
|
|
pref = item.get("preference", -1)
|
|
else:
|
|
pref = item.get("priority", -1)
|
|
try:
|
|
pref = int(pref)
|
|
except (TypeError, ValueError):
|
|
pref = -1
|
|
return pref if str(pref) not in {"", "-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:
|
|
"""
|
|
构造PUT创建记录的单条 body
|
|
各类型使用不同值字段:
|
|
A/AAAA -> address,
|
|
CNAME -> target,
|
|
MX -> exchange+preference
|
|
NS -> host,
|
|
TXT -> value,
|
|
CAA -> flag+tag+value
|
|
"""
|
|
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 in ("A", "AAAA"):
|
|
body = {"type": record_type, "name": name, "address": record_value, "ttl": ttl_val}
|
|
|
|
elif record_type == "CNAME":
|
|
body = {"type": record_type, "name": name, "cname": record_value, "ttl": ttl_val}
|
|
|
|
elif record_type == "NS":
|
|
body = {"type": record_type, "name": name, "nameserver": record_value, "ttl": ttl_val}
|
|
|
|
elif 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":
|
|
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:
|
|
# TXT 及其他
|
|
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, **kwargs) -> dict:
|
|
"""构造DELETE单条记录匹配 body, 各类型值字段不同"""
|
|
record_type = str(record_type or "").upper()
|
|
name = self._get_sub_domain(zone_domain, record) or record
|
|
val = kwargs.get("record_value") or ""
|
|
|
|
if record_type in ("A", "AAAA"):
|
|
body = {"type": record_type, "name": name, "address": val}
|
|
|
|
elif record_type == "CNAME":
|
|
body = {"type": record_type, "name": name, "cname": val}
|
|
|
|
elif record_type == "NS":
|
|
body = {"type": record_type, "name": name, "nameserver": val}
|
|
|
|
elif record_type == "MX":
|
|
body = {
|
|
"type": record_type, "name": name,
|
|
"exchange": val,
|
|
"preference": int(kwargs.get("priority", 0)),
|
|
}
|
|
|
|
elif record_type == "CAA":
|
|
parts = str(val).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("'"),
|
|
}
|
|
else:
|
|
body = {"type": record_type, "name": name, "value": val}
|
|
|
|
else:
|
|
# TXT / 其他
|
|
body = {"type": record_type, "name": name, "value": val}
|
|
return body
|
|
|
|
# =============== acme ======================
|
|
def create_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
|
|
_, _, acme_txt = extract_zone(domain_name)
|
|
res = self.create_org_record(
|
|
domain_name=domain_name,
|
|
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:
|
|
_, _, acme_txt = extract_zone(domain_name)
|
|
res = self.remove_record(domain_name, 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) -> 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)
|
|
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,
|
|
)
|
|
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
|
|
|
|
|
|
# noinspection PyUnusedLocal
|
|
class AmazonRoute53Dns(BaseDns):
|
|
dns_provider_name = "route53"
|
|
kw_prefix = {
|
|
"priority": "priority"
|
|
}
|
|
# DNS 记录类型中支持多值的(同一 name + type 可存多个value)
|
|
_MULTI_VALUE_TYPES = {"TXT", "CAA", "MX"}
|
|
|
|
def __init__(self, api_user, api_key, **kwargs): # noqa
|
|
super().__init__()
|
|
self.api_user = str(api_user or "").strip() # AWS AccessKeyId
|
|
self.api_key = str(api_key or "").strip() # AWS SecretAccessKey
|
|
try:
|
|
import boto3
|
|
from botocore.config import Config
|
|
from botocore.exceptions import ClientError
|
|
except ImportError:
|
|
os.system("btpip install boto3")
|
|
try:
|
|
import boto3
|
|
from botocore.config import Config
|
|
from botocore.exceptions import ClientError
|
|
except ImportError:
|
|
raise HintException("Aws SDK boto3 is not installed, please install it first: btpip install boto3")
|
|
|
|
self.boto3 = boto3
|
|
self.ClientError = ClientError
|
|
self.Config = Config
|
|
session = self.boto3.Session(
|
|
aws_access_key_id=self.api_user,
|
|
aws_secret_access_key=self.api_key,
|
|
region_name="us-east-1",
|
|
)
|
|
config = self.Config(retries={"max_attempts": 3, "mode": "standard"})
|
|
self.r53 = session.client("route53", config=config)
|
|
self._hosted_zones_cache = None
|
|
|
|
def _find_hosted_zone(self, domain_name: str) -> Optional[str]:
|
|
"""查找域名对应的Zone (最长匹配, 参考cf处理)"""
|
|
domain = domain_name.strip().rstrip(".").lstrip("*.")
|
|
zones = self.get_domains()
|
|
matched = [
|
|
z for z in zones if domain == z or domain.endswith("." + z)
|
|
]
|
|
if not matched:
|
|
return None
|
|
return max(matched, key=lambda x: len(x))
|
|
|
|
def _get_zone_id(self, domain_name: str) -> str:
|
|
"""获取托管区 ID"""
|
|
domain = str(domain_name or "").strip().rstrip(".")
|
|
if not domain.endswith("."):
|
|
domain = domain + "."
|
|
|
|
paginator = self.r53.get_paginator("list_hosted_zones")
|
|
for page in paginator.paginate():
|
|
for zone in page.get("HostedZones", []):
|
|
zone_name = zone.get("Name", "").rstrip(".")
|
|
if zone_name == domain.rstrip("."):
|
|
zone_id = zone.get("Id", "")
|
|
if zone_id.startswith("/hostedzone/"):
|
|
zone_id = zone_id[len("/hostedzone/"):]
|
|
return zone_id
|
|
|
|
raise HintException(f"Hosted zone not found for domain: {domain_name}")
|
|
|
|
@staticmethod
|
|
def _normalize_host(domain_name: str, record: str) -> str:
|
|
"""标准化子域名
|
|
|
|
处理输入的record可能带域名也可能不带域名的情况:
|
|
- "test" -> "test"
|
|
- "test.example.com" -> "test"
|
|
- "test.example.com." -> "test"
|
|
- "@" -> "@"
|
|
- "example.com" -> "@"
|
|
"""
|
|
# 标准化domain_name,去掉末尾点
|
|
domain = str(domain_name or "").strip().rstrip(".")
|
|
# 标准化record,去掉末尾点和空格
|
|
record = str(record or "").strip().rstrip(".")
|
|
|
|
if not record or record in ("@", domain):
|
|
return "@"
|
|
|
|
# 检查record是否包含完整域名,如果是则提取子域名部分
|
|
suffix = f".{domain}"
|
|
if record.endswith(suffix):
|
|
return record[:-len(suffix)] or "@"
|
|
|
|
# record本身就是一个子域名(不带domain后缀)
|
|
return record
|
|
|
|
@staticmethod
|
|
def _parse_caa_value(value: str) -> tuple:
|
|
"""解析 CAA 值: 'flag tag value' -> (flag, tag, value)"""
|
|
parts = str(value or "").strip().split(None, 2)
|
|
if len(parts) != 3:
|
|
raise HintException("CAA record_value format error, expected: '<flag> <tag> <value>'")
|
|
flag_str, tag, caa_value = parts
|
|
if not tag or caa_value == "":
|
|
raise HintException("CAA record_value format error, expected: '<flag> <tag> <value>'")
|
|
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> <tag> <value>'")
|
|
try:
|
|
flag = int(flag_str)
|
|
except Exception:
|
|
raise HintException("CAA flag must be integer")
|
|
return flag, tag, caa_value
|
|
|
|
@staticmethod
|
|
def _format_caa_value(flag: int, tag: str, caa_value: str) -> str:
|
|
"""格式化 CAA 值为 Route53 Value: 'flag tag "value"'"""
|
|
return f'{flag} {tag} "{caa_value}"'
|
|
|
|
@staticmethod
|
|
def _ensure_upper(s: str) -> str:
|
|
"""确保字符串大写"""
|
|
return str(s or "").upper()
|
|
|
|
@staticmethod
|
|
def _ensure_trailing_dot(name: str) -> str:
|
|
"""确保域名以点结尾"""
|
|
return name + "." if name and not name.endswith(".") else name
|
|
|
|
def _build_r53_value(self, record_type: str, record_value: str, priority: int = -1) -> str:
|
|
"""构造各类型使用不同字段格式"""
|
|
record_type = self._ensure_upper(record_type)
|
|
|
|
if record_type == "MX":
|
|
pref = priority if str(priority) not in {"", "-1", "None"} else 10
|
|
return f"{pref} {record_value}"
|
|
|
|
if record_type == "TXT":
|
|
val = str(record_value or "")
|
|
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
|
|
val = val[1:-1]
|
|
return f'"{val}"'
|
|
|
|
if record_type == "CAA":
|
|
flag, tag, caa_val = self._parse_caa_value(record_value)
|
|
return self._format_caa_value(flag, tag, caa_val)
|
|
|
|
return record_value
|
|
|
|
def _parse_r53_value(self, record_type: str, value: str) -> str:
|
|
"""解析返回的 Value 为外部格式"""
|
|
record_type = self._ensure_upper(record_type)
|
|
value = str(value or "")
|
|
|
|
if record_type == "TXT":
|
|
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
|
value = value[1:-1]
|
|
return value
|
|
|
|
if record_type == "MX":
|
|
parts = value.strip().split(None, 1)
|
|
return parts[1] if len(parts) > 1 else value
|
|
|
|
if record_type == "CAA":
|
|
parts = value.strip().split(None, 2)
|
|
if len(parts) == 3:
|
|
flag_str, tag, caa_val = parts
|
|
if (caa_val.startswith('"') and caa_val.endswith('"')) or (
|
|
caa_val.startswith("'") and caa_val.endswith("'")):
|
|
caa_val = caa_val[1:-1]
|
|
return f"{flag_str} {tag} {caa_val}"
|
|
return value
|
|
|
|
return value
|
|
|
|
@staticmethod
|
|
def _parse_priority(record_type: str, value: str) -> int:
|
|
"""提取优先级: MX 从 value 解析"""
|
|
record_type = str(record_type or "").upper()
|
|
if record_type == "MX":
|
|
parts = str(value or "").strip().split(None, 1)
|
|
try:
|
|
return int(parts[0])
|
|
except (ValueError, IndexError):
|
|
return -1
|
|
return -1
|
|
|
|
def _get_full_name(self, domain_name: str, record: str) -> str:
|
|
"""获取完整域名 (不带末尾点)"""
|
|
host = self._normalize_host(domain_name, record)
|
|
return domain_name if host == "@" else f"{host}.{domain_name}"
|
|
|
|
def _make_change(self, action: str, name: str, record_type: str,
|
|
ttl: int, value: str, priority: int = -1) -> dict:
|
|
"""构造单条 Change 字典, 支持 A/AAAA/MX/TXT/NS/CAA/CNAME"""
|
|
record_type = self._ensure_upper(record_type)
|
|
|
|
# CAA 需要特殊处理(值格式不同)
|
|
if record_type == "CAA":
|
|
flag, tag, caa_val = self._parse_caa_value(value)
|
|
r53_val = self._format_caa_value(flag, tag, caa_val)
|
|
return {
|
|
"Action": action,
|
|
"ResourceRecordSet": {
|
|
"Name": self._ensure_trailing_dot(name),
|
|
"Type": "CAA",
|
|
"TTL": ttl,
|
|
"ResourceRecords": [{"Value": r53_val}],
|
|
},
|
|
}
|
|
|
|
# DELETE 操作使用原始值,CREATE 操作需要格式化
|
|
r_value = value if action == "DELETE" else self._build_r53_value(record_type, value, priority)
|
|
|
|
return {
|
|
"Action": action,
|
|
"ResourceRecordSet": {
|
|
"Name": self._ensure_trailing_dot(name),
|
|
"Type": record_type,
|
|
"TTL": ttl,
|
|
"ResourceRecords": [{"Value": r_value}],
|
|
},
|
|
}
|
|
|
|
# =============== 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)
|
|
# 优化获取现有 TXT 记录
|
|
existing_values = []
|
|
try:
|
|
existing_records = self._get_acme_txt_record(domain_name, acme_txt)
|
|
for r in existing_records:
|
|
val = r.get("record_value", "")
|
|
if val and val not in existing_values:
|
|
existing_values.append(val)
|
|
except Exception:
|
|
pass
|
|
|
|
# 合并去重
|
|
all_values = existing_values + [domain_dns_value]
|
|
all_values = list(dict.fromkeys(all_values).keys())
|
|
|
|
# 创建或更新记录
|
|
if len(all_values) == 1:
|
|
# 单值使用普通创建
|
|
res = self.create_org_record(
|
|
domain_name=domain_name,
|
|
record=acme_txt,
|
|
record_value=domain_dns_value,
|
|
record_type="TXT",
|
|
ttl=600,
|
|
)
|
|
else:
|
|
# 多值使用UPSERT合并所有值
|
|
res = self._upsert_dns_record_multi(
|
|
domain_name=domain_name,
|
|
record=acme_txt,
|
|
record_values=all_values,
|
|
record_type="TXT",
|
|
ttl=600,
|
|
)
|
|
|
|
if not res.get("status"):
|
|
raise HintException(res.get("msg") or "create dns record failed")
|
|
|
|
def _make_change_multi(self, action: str, name: str, record_type: str,
|
|
ttl: int, values: list[str]) -> dict:
|
|
"""
|
|
构造多值的 Change 字典
|
|
用于一个记录名存储多个 TXT 值(ACME 多域名挑战)
|
|
"""
|
|
record_type = self._ensure_upper(record_type)
|
|
|
|
# 为 TXT 记录构建多个值
|
|
resource_records = []
|
|
for value in values:
|
|
if record_type == "TXT":
|
|
# TXT 记录需要带引号
|
|
formatted_value = self._build_r53_value(record_type, value, -1)
|
|
resource_records.append({"Value": formatted_value})
|
|
else:
|
|
resource_records.append({"Value": value})
|
|
|
|
return {
|
|
"Action": action,
|
|
"ResourceRecordSet": {
|
|
"Name": self._ensure_trailing_dot(name),
|
|
"Type": record_type,
|
|
"TTL": ttl,
|
|
"ResourceRecords": resource_records,
|
|
},
|
|
}
|
|
|
|
def _upsert_dns_record_multi(self, domain_name: str, record: str, record_values: list[str],
|
|
record_type: str, ttl: int = 600) -> dict:
|
|
"""
|
|
创建或更新 DNS 记录,支持多值(ACME 批处理)
|
|
"""
|
|
domain_name = self._find_hosted_zone(domain_name) or domain_name
|
|
try:
|
|
zone_id = self._get_zone_id(domain_name)
|
|
full_name = self._get_full_name(domain_name, record)
|
|
|
|
ttl_val = 600 if int(ttl) == 1 else int(ttl)
|
|
|
|
# 使用多值 Change
|
|
change = self._make_change_multi("UPSERT", full_name, record_type, ttl_val, record_values)
|
|
if self._change_record(zone_id, "UPSERT", [change]):
|
|
return {"status": True, "msg": "success"}
|
|
return {"status": False, "msg": "upsert dns record failed"}
|
|
except HintException:
|
|
raise
|
|
except Exception as e:
|
|
return {"status": False, "msg": str(e)}
|
|
|
|
def delete_dns_record(self, domain_name: str, domain_dns_value: str) -> None:
|
|
domain_name = domain_name.lstrip("*.")
|
|
_, _, acme_txt = extract_zone(domain_name)
|
|
res = self.remove_record(
|
|
domain_name, 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) -> list:
|
|
if self._hosted_zones_cache is not None and not verify:
|
|
return self._hosted_zones_cache
|
|
|
|
try:
|
|
domains = []
|
|
paginator = self.r53.get_paginator("list_hosted_zones")
|
|
for page in paginator.paginate():
|
|
for zone in page.get("HostedZones", []):
|
|
# 私有托管区
|
|
if zone.get("Config", {}).get("PrivateZone", False):
|
|
continue
|
|
if verify:
|
|
return []
|
|
name = zone.get("Name", "").strip().rstrip(".")
|
|
if name:
|
|
domains.append(name)
|
|
result = sorted(set(domains))
|
|
if not verify:
|
|
self._hosted_zones_cache = result
|
|
return result
|
|
except self.ClientError as e:
|
|
raise HintException(f"get domains error: {e}")
|
|
except Exception as e:
|
|
raise HintException(f"get domains error {e}")
|
|
|
|
def get_dns_record(self, domain_name: str) -> list:
|
|
zone_domain = self._find_hosted_zone(domain_name)
|
|
if not zone_domain:
|
|
raise HintException(f"Unable to find hosted zone for domain: {domain_name}")
|
|
|
|
try:
|
|
zone_id = self._get_zone_id(zone_domain)
|
|
result = []
|
|
|
|
paginator = self.r53.get_paginator("list_resource_record_sets")
|
|
for page in paginator.paginate(HostedZoneId=zone_id):
|
|
for rs in page.get("ResourceRecordSets", []):
|
|
r_type = rs.get("Type", "").upper()
|
|
r_name = rs.get("Name", "").strip().rstrip(".")
|
|
r_ttl = rs.get("TTL", 600)
|
|
host = self._normalize_host(zone_domain, r_name)
|
|
# 跳过 SOA 记录
|
|
if r_type == "SOA":
|
|
continue
|
|
# 跳过zone根 NS 记录
|
|
if r_type == "NS" and host == "@" and zone_domain in r_name:
|
|
continue
|
|
for rr in rs.get("ResourceRecords", []):
|
|
r_value = rr.get("Value", "")
|
|
record_value = self._parse_r53_value(r_type, r_value)
|
|
priority = self._parse_priority(r_type, r_value)
|
|
result.append({
|
|
"record": host,
|
|
"record_value": record_value,
|
|
"record_type": r_type,
|
|
"ttl": r_ttl,
|
|
"priority": priority,
|
|
"proxy": -1,
|
|
})
|
|
|
|
return result
|
|
except self.ClientError as e:
|
|
raise HintException(f"get dns record error: {e}")
|
|
except Exception as e:
|
|
raise HintException(f"get dns record error {e}")
|
|
|
|
def _get_acme_txt_record(self, domain_name: str, record: str) -> list:
|
|
"""
|
|
获取 ACME record TXT 挑战记录(ACME 专用优化快速查找)
|
|
"""
|
|
zone_domain = self._find_hosted_zone(domain_name)
|
|
if not zone_domain:
|
|
return []
|
|
|
|
try:
|
|
zone_id = self._get_zone_id(zone_domain)
|
|
target_host = self._normalize_host(domain_name, record)
|
|
full_name_with_dot = self._get_full_name(domain_name, record) + "."
|
|
|
|
paginator = self.r53.get_paginator("list_resource_record_sets")
|
|
for page in paginator.paginate(HostedZoneId=zone_id):
|
|
for rs in page.get("ResourceRecordSets", []):
|
|
r_type = rs.get("Type", "").upper()
|
|
r_name = rs.get("Name", "")
|
|
if r_type != "TXT":
|
|
continue
|
|
# 标准化记录名进行比较
|
|
r_name_stripped = r_name.strip().rstrip(".")
|
|
if r_name_stripped != full_name_with_dot.rstrip("."):
|
|
continue
|
|
# 解析值并返回
|
|
r_ttl = rs.get("TTL", 600)
|
|
result = []
|
|
for rr in rs.get("ResourceRecords", []):
|
|
r_value = rr.get("Value", "")
|
|
record_value = self._parse_r53_value(r_type, r_value)
|
|
result.append({
|
|
"record": target_host,
|
|
"record_value": record_value,
|
|
"record_type": r_type,
|
|
"ttl": r_ttl,
|
|
"priority": -1,
|
|
"proxy": -1,
|
|
})
|
|
return result
|
|
return []
|
|
except Exception as e:
|
|
public.print_log(f"get acme txt record error: {e}")
|
|
return []
|
|
|
|
def _change_record(self, zone_id: str, action: str, changes: list) -> bool:
|
|
"""执行 Record 变更, action: CREATE/DELETE/UPSERT"""
|
|
try:
|
|
self.r53.change_resource_record_sets(
|
|
HostedZoneId=zone_id,
|
|
ChangeBatch={
|
|
"Changes": changes,
|
|
},
|
|
)
|
|
return True
|
|
except self.ClientError as e:
|
|
error_code = e.response.get("Error", {}).get("Code", "")
|
|
error_msg = e.response.get("Error", {}).get("Message", "")
|
|
# DELETE 时记录不存在视为成功
|
|
if action == "DELETE" and error_code == "InvalidChangeBatch" and "but it does not exist" in error_msg:
|
|
return True
|
|
public.print_log(f"Route53 {action} error: {error_msg}")
|
|
return False
|
|
except Exception as e:
|
|
public.print_log(f"Route53 {action} error: {e}")
|
|
return False
|
|
|
|
def create_org_record(self, domain_name, record, record_value, record_type, ttl=1, **kwargs):
|
|
"""
|
|
创建 DNS 记录
|
|
- 可合并类型存在时合并新值
|
|
- 不可合并类型存在时直接返回失败, 避免创建表数据
|
|
- 不存在时正常创建
|
|
"""
|
|
domain_name = self._find_hosted_zone(domain_name) or domain_name
|
|
try:
|
|
zone_id = self._get_zone_id(domain_name)
|
|
full_name = self._get_full_name(domain_name, record)
|
|
record_type_upper = self._ensure_upper(str(record_type))
|
|
|
|
ttl_val = 600 if int(ttl) == 1 else int(ttl)
|
|
priority = kwargs.get("priority", -1)
|
|
|
|
if record_type_upper in self._MULTI_VALUE_TYPES:
|
|
# 可合并类型: 查询现有值并追加
|
|
existing_values = []
|
|
records = self.get_dns_record(domain_name)
|
|
target_host = self._normalize_host(domain_name, record)
|
|
for r in records:
|
|
if (self._normalize_host(domain_name, r.get("record")) == target_host and
|
|
self._ensure_upper(r.get("record_type", "")) == record_type_upper):
|
|
val = r.get("record_value", "")
|
|
if val and val not in existing_values:
|
|
existing_values.append(val)
|
|
|
|
# 追加新值(去重)
|
|
normalized_new_value = self._parse_r53_value(
|
|
record_type_upper, record_value
|
|
) if record_type_upper in ("TXT", "CAA") else record_value
|
|
|
|
if normalized_new_value not in existing_values:
|
|
existing_values.append(normalized_new_value)
|
|
|
|
if len(existing_values) == 1:
|
|
change = self._make_change("UPSERT", full_name, record_type_upper,
|
|
ttl_val, existing_values[0], priority)
|
|
else:
|
|
change = self._make_change_multi("UPSERT", full_name, record_type_upper,
|
|
ttl_val, existing_values)
|
|
|
|
if self._change_record(zone_id, "UPSERT", [change]):
|
|
return {"status": True, "msg": "success"}
|
|
return {"status": False, "msg": "upsert record failed"}
|
|
else:
|
|
# 不可合并类型: 检查是否存在
|
|
records = self.get_dns_record(domain_name)
|
|
target_host = self._normalize_host(domain_name, record)
|
|
for r in records:
|
|
if (self._normalize_host(domain_name, r.get("record")) == target_host and
|
|
self._ensure_upper(r.get("record_type", "")) == record_type_upper):
|
|
# 已存在,返回失败, 避免添加到缓存表中
|
|
return {"status": False, "msg": "Record already exists."}
|
|
|
|
# 不存在,正常创建
|
|
change = self._make_change("UPSERT", full_name, record_type_upper,
|
|
ttl_val, record_value, priority)
|
|
if self._change_record(zone_id, "UPSERT", [change]):
|
|
return {"status": True, "msg": "success"}
|
|
return {"status": False, "msg": "create record failed"}
|
|
except HintException:
|
|
raise
|
|
except Exception as e:
|
|
return {"status": False, "msg": str(e)}
|
|
|
|
def remove_record(self, domain_name, record, record_type="TXT", **kwargs) -> dict:
|
|
"""
|
|
删除 DNS 记录
|
|
多值类型+指定值: 仅移除该值, 保留其余
|
|
其余情况: 删除全部匹配记录
|
|
"""
|
|
domain_name = self._find_hosted_zone(domain_name) or domain_name
|
|
try:
|
|
zone_id = self._get_zone_id(domain_name)
|
|
full_name = self._get_full_name(domain_name, record)
|
|
record_type_upper = str(record_type).upper()
|
|
|
|
# 查询所有记录
|
|
records = self.get_dns_record(domain_name)
|
|
target_host = self._normalize_host(domain_name, record)
|
|
|
|
# 收集所有匹配的记录值
|
|
matched_records = []
|
|
for r in records:
|
|
if (self._normalize_host(domain_name, r.get("record")) != target_host or
|
|
r.get("record_type", "").upper() != record_type_upper):
|
|
continue
|
|
matched_records.append(r)
|
|
|
|
if not matched_records:
|
|
return {"status": True, "msg": "Dns Record not found."}
|
|
|
|
provided_value = kwargs.get("record_value", "")
|
|
ttl = matched_records[0].get("ttl", 600)
|
|
priority = matched_records[0].get("priority", -1)
|
|
|
|
# === 多值类型 + 指定了具体值 → 只删这一个值, 保留其余 ===
|
|
if record_type_upper in self._MULTI_VALUE_TYPES and provided_value:
|
|
# 标准化要删除的值
|
|
compare_value = self._parse_r53_value(record_type_upper, provided_value)
|
|
|
|
# 检查值是否存在
|
|
if compare_value not in [r.get("record_value", "") for r in matched_records]:
|
|
return {"status": False, "msg": f"No matching record found with value: {provided_value}"}
|
|
|
|
# 过滤出要保留的值
|
|
remaining_values = [
|
|
r.get("record_value", "") for r in matched_records
|
|
if r.get("record_value", "") != compare_value
|
|
]
|
|
|
|
if remaining_values:
|
|
# 还有剩余值 → UPSERT 保留
|
|
if len(remaining_values) == 1:
|
|
change = self._make_change("UPSERT", full_name, record_type_upper,
|
|
ttl, remaining_values[0], priority)
|
|
else:
|
|
change = self._make_change_multi("UPSERT", full_name, record_type_upper,
|
|
ttl, remaining_values)
|
|
else:
|
|
# 没有剩余值 → DELETE 整个记录集
|
|
change = self._make_change_multi("DELETE", full_name, record_type_upper,
|
|
ttl, [r.get("record_value", "") for r in matched_records])
|
|
|
|
if self._change_record(zone_id, change.get("Action"), [change]):
|
|
return {"status": True, "msg": "success"}
|
|
return {"status": False, "msg": "delete record failed"}
|
|
|
|
# === 其余情况: 删除全部匹配记录 ===
|
|
change = self._make_change_multi("DELETE", full_name, record_type_upper, ttl,
|
|
[r.get("record_value", "") for r in matched_records])
|
|
if self._change_record(zone_id, "DELETE", [change]):
|
|
return {"status": True, "msg": "success"}
|
|
return {"status": False, "msg": "delete record failed"}
|
|
except HintException:
|
|
raise
|
|
except Exception as e:
|
|
return {"status": False, "msg": str(e)}
|
|
|
|
def update_record(self, domain_name, record: dict, new_record: dict, **kwargs):
|
|
"""
|
|
更新 DNS 记录
|
|
多值类型同 name+type 时: 在值列表中替换, 保留其他值
|
|
其余情况: 先删后建或直接 UPSERT
|
|
"""
|
|
domain_name = self._find_hosted_zone(domain_name) or domain_name
|
|
try:
|
|
old_full_name = self._get_full_name(domain_name, record.get("record"))
|
|
new_full_name = self._get_full_name(domain_name, new_record.get("record"))
|
|
old_record_type = record.get("record_type", "TXT")
|
|
new_record_type = new_record.get("record_type", "TXT")
|
|
old_record_type_upper = self._ensure_upper(old_record_type)
|
|
new_record_type_upper = self._ensure_upper(new_record_type)
|
|
|
|
ttl_val = int(new_record.get("ttl", 600))
|
|
if ttl_val == 1:
|
|
ttl_val = 600
|
|
|
|
priority = new_record.get("priority", -1)
|
|
|
|
# === 记录名或类型变化 → 先删后建 ===
|
|
if old_full_name != new_full_name or old_record_type_upper != new_record_type_upper:
|
|
del_res = self.remove_record(domain_name, record.get("record"), old_record_type)
|
|
if not del_res.get("status"):
|
|
return {"status": False, "msg": f"delete old record failed: {del_res.get('msg')}"}
|
|
|
|
create_change = self._make_change(
|
|
"UPSERT", new_full_name, new_record_type_upper,
|
|
ttl_val, new_record.get("record_value", ""), priority,
|
|
)
|
|
if self._change_record(self._get_zone_id(domain_name), "UPSERT", [create_change]):
|
|
return {"status": True, "msg": "success"}
|
|
return {"status": False, "msg": "create new record failed"}
|
|
|
|
# === name+type 相同 ===
|
|
if new_record_type_upper in self._MULTI_VALUE_TYPES:
|
|
# 多值类型: 在值列表中替换指定值, 保留其他值
|
|
records = self.get_dns_record(domain_name)
|
|
target_host = self._normalize_host(domain_name, record.get("record"))
|
|
existing_values = [
|
|
r.get("record_value", "") for r in records
|
|
if (self._normalize_host(domain_name, r.get("record")) == target_host and
|
|
self._ensure_upper(r.get("record_type", "")) == new_record_type_upper)
|
|
]
|
|
|
|
old_value = record.get("record_value", "")
|
|
new_value = new_record.get("record_value", "")
|
|
|
|
# 找到旧值并替换
|
|
if old_value in existing_values:
|
|
idx = existing_values.index(old_value)
|
|
existing_values[idx] = new_value
|
|
else:
|
|
# 旧值不在列表中, 检查是否只是格式差异(TXT引号)
|
|
normalized_old = self._parse_r53_value(new_record_type_upper, old_value)
|
|
found = False
|
|
for i, v in enumerate(existing_values):
|
|
if v == normalized_old:
|
|
existing_values[i] = new_value
|
|
found = True
|
|
break
|
|
if not found:
|
|
# 找不到旧值, 视为新增
|
|
if new_value not in existing_values:
|
|
existing_values.append(new_value)
|
|
|
|
# 去重
|
|
existing_values = list(dict.fromkeys(existing_values))
|
|
|
|
if len(existing_values) == 1:
|
|
change = self._make_change("UPSERT", new_full_name, new_record_type_upper,
|
|
ttl_val, existing_values[0], priority)
|
|
else:
|
|
change = self._make_change_multi("UPSERT", new_full_name, new_record_type_upper,
|
|
ttl_val, existing_values)
|
|
else:
|
|
# 单值类型: 直接 UPSERT 覆盖
|
|
change = self._make_change(
|
|
"UPSERT", new_full_name, new_record_type_upper,
|
|
ttl_val, new_record.get("record_value", ""), priority,
|
|
)
|
|
|
|
if self._change_record(self._get_zone_id(domain_name), "UPSERT", [change]):
|
|
return {"status": True, "msg": "success"}
|
|
return {"status": False, "msg": "update record failed"}
|
|
|
|
except HintException:
|
|
raise
|
|
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 AccessKeyId and SecretAccessKey: {e}")
|
|
return True
|