ddns 4.0.1__py2.py3-none-any.whl → 4.1.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ddns might be problematic. Click here for more details.

@@ -0,0 +1,113 @@
1
+ # coding=utf-8
2
+ """
3
+ DNS Provider 签名和哈希算法模块
4
+
5
+ Signature and hash algorithms module for DNS providers.
6
+ Provides common cryptographic functions for cloud provider authentication.
7
+
8
+ @author: NewFuture
9
+ """
10
+
11
+ from hashlib import sha256
12
+ from hmac import HMAC
13
+
14
+
15
+ def hmac_sha256(key, message):
16
+ # type: (str | bytes, str | bytes) -> HMAC
17
+ """
18
+ 计算 HMAC-SHA256 签名对象
19
+
20
+ Compute HMAC-SHA256 signature object.
21
+
22
+ Args:
23
+ key (str | bytes): 签名密钥 / Signing key
24
+ message (str | bytes): 待签名消息 / Message to sign
25
+
26
+ Returns:
27
+ HMAC: HMAC签名对象,可调用.digest()获取字节或.hexdigest()获取十六进制字符串
28
+ HMAC signature object, call .digest() for bytes or .hexdigest() for hex string
29
+ """
30
+ # Python 2/3 compatible encoding - avoid double encoding in Python 2
31
+ if not isinstance(key, bytes):
32
+ key = key.encode("utf-8")
33
+ if not isinstance(message, bytes):
34
+ message = message.encode("utf-8")
35
+ return HMAC(key, message, sha256)
36
+
37
+
38
+ def sha256_hash(data):
39
+ # type: (str | bytes) -> str
40
+ """
41
+ 计算 SHA256 哈希值
42
+
43
+ Compute SHA256 hash.
44
+
45
+ Args:
46
+ data (str | bytes): 待哈希数据 / Data to hash
47
+
48
+ Returns:
49
+ str: 十六进制哈希字符串 / Hexadecimal hash string
50
+ """
51
+ # Python 2/3 compatible encoding - avoid double encoding in Python 2
52
+ if not isinstance(data, bytes):
53
+ data = data.encode("utf-8")
54
+ return sha256(data).hexdigest()
55
+
56
+
57
+ def hmac_sha256_authorization(
58
+ secret_key, # type: str | bytes
59
+ method, # type: str
60
+ path, # type: str
61
+ query, # type: str
62
+ headers, # type: dict[str, str]
63
+ body_hash, # type: str
64
+ signing_string_format, # type: str
65
+ authorization_format, # type: str
66
+ ):
67
+ # type: (...) -> str
68
+ """
69
+ HMAC-SHA256 云服务商通用认证签名生成器
70
+
71
+ Universal cloud provider authentication signature generator using HMAC-SHA256.
72
+
73
+ 通用的云服务商API认证签名生成函数,使用HMAC-SHA256算法生成符合各云服务商规范的Authorization头部。
74
+
75
+ 模板变量格式:{HashedCanonicalRequest}, {SignedHeaders}, {Signature}
76
+
77
+ Args:
78
+ secret_key (str | bytes): 签名密钥,已经过密钥派生处理 / Signing key (already derived if needed)
79
+ method (str): HTTP请求方法 / HTTP request method (GET, POST, etc.)
80
+ path (str): API请求路径 / API request path
81
+ query (str): URL查询字符串 / URL query string
82
+ headers (dict[str, str]): HTTP请求头部 / HTTP request headers
83
+ body_hash (str): 请求体的SHA256哈希值 / SHA256 hash of request payload
84
+ signing_string_format (str): 待签名字符串模板,包含 {HashedCanonicalRequest} 占位符
85
+ authorization_format (str): Authorization头部模板,包含 {SignedHeaders}, {Signature} 占位符
86
+
87
+ Returns:
88
+ str: 完整的Authorization头部值 / Complete Authorization header value
89
+ """
90
+ # 1. 构建规范化头部 - 所有传入的头部都参与签名
91
+ headers_to_sign = {k.lower(): str(v).strip() for k, v in headers.items()}
92
+ signed_headers_list = sorted(headers_to_sign.keys())
93
+
94
+ # 2. 构建规范请求字符串
95
+ canonical_headers = ""
96
+ for header_name in signed_headers_list:
97
+ canonical_headers += "{}:{}\n".format(header_name, headers_to_sign[header_name])
98
+
99
+ # 构建完整的规范请求字符串
100
+ signed_headers = ";".join(signed_headers_list)
101
+ canonical_request = "\n".join([method.upper(), path, query, canonical_headers, signed_headers, body_hash])
102
+
103
+ # 3. 构建待签名字符串 - 只需要替换 HashedCanonicalRequest
104
+ hashed_canonical_request = sha256_hash(canonical_request)
105
+ string_to_sign = signing_string_format.format(HashedCanonicalRequest=hashed_canonical_request)
106
+
107
+ # 4. 计算最终签名
108
+ signature = hmac_sha256(secret_key, string_to_sign).hexdigest()
109
+
110
+ # 5. 构建Authorization头部 - 只需要替换 SignedHeaders 和 Signature
111
+ authorization = authorization_format.format(SignedHeaders=signed_headers, Signature=signature)
112
+
113
+ return authorization
ddns/provider/alidns.py CHANGED
@@ -2,182 +2,151 @@
2
2
  """
3
3
  AliDNS API
4
4
  阿里DNS解析操作库
5
- https://help.aliyun.com/document_detail/29739.html
6
- @author: New Future
5
+ @author: NewFuture
7
6
  """
8
7
 
9
- from hashlib import sha1
10
- from hmac import new as hmac
11
- from uuid import uuid4
12
- from base64 import b64encode
13
- from json import loads as jsondecode
14
- from logging import debug, info, warning
15
- from datetime import datetime
16
-
17
- try: # python 3
18
- from http.client import HTTPSConnection
19
- from urllib.parse import urlencode, quote_plus, quote
20
- except ImportError: # python 2
21
- from httplib import HTTPSConnection
22
- from urllib import urlencode, quote_plus, quote
23
-
24
- __author__ = 'New Future'
25
- # __all__ = ["request", "ID", "TOKEN", "PROXY"]
26
-
27
-
28
- class Config:
29
- ID = "id"
30
- TOKEN = "TOKEN"
31
- PROXY = None # 代理设置
32
- TTL = None
33
-
34
-
35
- class API:
36
- # API 配置
37
- SITE = "alidns.aliyuncs.com" # API endpoint
38
- METHOD = "POST" # 请求方法
39
-
40
-
41
- def signature(params):
42
- """
43
- 计算签名,返回签名后的查询参数
44
- """
45
- params.update({
46
- 'Format': 'json',
47
- 'Version': '2015-01-09',
48
- 'AccessKeyId': Config.ID,
49
- 'Timestamp': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
50
- 'SignatureMethod': 'HMAC-SHA1',
51
- 'SignatureNonce': uuid4(),
52
- 'SignatureVersion': "1.0",
53
- })
54
- query = urlencode(sorted(params.items()))
55
- query = query.replace('+', '%20')
56
- debug(query)
57
- sign = API.METHOD + "&" + quote_plus("/") + "&" + quote(query, safe='')
58
- debug("signString: %s", sign)
59
-
60
- sign = hmac((Config.TOKEN + "&").encode('utf-8'),
61
- sign.encode('utf-8'), sha1).digest()
62
- sign = b64encode(sign).strip()
63
- params["Signature"] = sign
64
- return params
65
-
66
-
67
- def request(param=None, **params):
68
- """
69
- 发送请求数据
70
- """
71
- if param:
72
- params.update(param)
73
- params = dict((k, params[k]) for k in params if params[k] is not None)
74
- params = signature(params)
75
- info("%s: %s", API.SITE, params)
76
-
77
- if Config.PROXY:
78
- conn = HTTPSConnection(Config.PROXY)
79
- conn.set_tunnel(API.SITE, 443)
80
- else:
81
- conn = HTTPSConnection(API.SITE)
82
- conn.request(API.METHOD, '/', urlencode(params),
83
- {"Content-type": "application/x-www-form-urlencoded"})
84
- response = conn.getresponse()
85
- data = response.read().decode('utf8')
86
- conn.close()
87
-
88
- if response.status < 200 or response.status >= 300:
89
- warning('%s : error[%d]: %s', params['Action'], response.status, data)
90
- raise Exception(data)
91
- else:
92
- data = jsondecode(data)
93
- debug('%s : result:%s', params['Action'], data)
94
- return data
95
-
96
-
97
- def get_domain_info(domain):
98
- """
99
- 切割域名获取主域名和对应ID
100
- https://help.aliyun.com/document_detail/29755.html
101
- http://alidns.aliyuncs.com/?Action=GetMainDomainName&InputString=www.example.com
102
- """
103
- res = request(Action="GetMainDomainName", InputString=domain)
104
- sub, main = res.get('RR'), res.get('DomainName')
105
- return sub, main
106
-
107
-
108
- def get_records(domain, **conditions):
109
- """
110
- 获取记录ID
111
- 返回满足条件的所有记录[]
112
- https://help.aliyun.com/document_detail/29776.html
113
- TODO 大于500翻页
114
- """
115
- if not hasattr(get_records, "records"):
116
- get_records.records = {} # "静态变量"存储已查询过的id
117
- get_records.keys = ("RecordId", "RR", "Type", "Line",
118
- "Locked", "Status", "Priority", "Value")
119
-
120
- if domain not in get_records.records:
121
- get_records.records[domain] = {}
122
- data = request(Action="DescribeDomainRecords",
123
- DomainName=domain, PageSize=500)
124
- if data:
125
- for record in data.get('DomainRecords').get('Record'):
126
- get_records.records[domain][record["RecordId"]] = {
127
- k: v for (k, v) in record.items() if k in get_records.keys}
128
- records = {}
129
- for (rid, record) in get_records.records[domain].items():
130
- for (k, value) in conditions.items():
131
- if record.get(k) != value:
132
- break
133
- else: # for else push
134
- records[rid] = record
135
- return records
136
-
137
-
138
- def update_record(domain, value, record_type='A'):
139
- """
140
- 更新记录
141
- update
142
- https://help.aliyun.com/document_detail/29774.html
143
- add
144
- https://help.aliyun.com/document_detail/29772.html?
145
- """
146
- debug(">>>>>%s(%s)", domain, record_type)
147
- sub, main = get_domain_info(domain)
148
- if not sub:
149
- raise Exception("invalid domain: [ %s ] " % domain)
150
-
151
- records = get_records(main, RR=sub, Type=record_type)
152
- result = {}
153
-
154
- if records:
155
- for (rid, record) in records.items():
156
- if record["Value"] != value:
157
- debug(sub, record)
158
- res = request(Action="UpdateDomainRecord", RecordId=rid,
159
- Value=value, RR=sub, Type=record_type, TTL=Config.TTL)
160
- if res:
161
- # update records
162
- get_records.records[main][rid]["Value"] = value
163
- result[rid] = res
164
- else:
165
- result[rid] = "update fail!\n" + str(res)
166
- else:
167
- result[rid] = domain
168
- else: # https://help.aliyun.com/document_detail/29772.html
169
- res = request(Action="AddDomainRecord", DomainName=main,
170
- Value=value, RR=sub, Type=record_type, TTL=Config.TTL)
171
- if res:
172
- # update records INFO
173
- rid = res.get('RecordId')
174
- get_records.records[main][rid] = {
175
- 'Value': value,
176
- "RecordId": rid,
177
- "RR": sub,
178
- "Type": record_type
179
- }
180
- result = res
8
+ from time import gmtime, strftime, time
9
+
10
+ from ._base import TYPE_FORM, BaseProvider, encode_params, join_domain
11
+ from ._signature import hmac_sha256_authorization, sha256_hash
12
+
13
+
14
+ class AliBaseProvider(BaseProvider):
15
+ """阿里云基础Provider,提供通用的_request方法"""
16
+
17
+ endpoint = "https://alidns.aliyuncs.com"
18
+ content_type = TYPE_FORM # 阿里云DNS API使用表单格式
19
+ api_version = "2015-01-09" # API版本,v3签名需要
20
+
21
+ def _request(self, action, method="POST", **params):
22
+ # type: (str, str, **(Any)) -> dict
23
+ """Aliyun v3 https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature"""
24
+ params = {k: v for k, v in params.items() if v is not None}
25
+
26
+ if method in ("GET", "DELETE"):
27
+ # For GET and DELETE requests, parameters go in query string
28
+ query_string = encode_params(params) if len(params) > 0 else ""
29
+ body_content = ""
30
+ else:
31
+ # For POST requests, parameters go in body
32
+ body_content = self._encode_body(params)
33
+ query_string = ""
34
+
35
+ path = "/"
36
+ content_hash = sha256_hash(body_content)
37
+ # 构造请求头部
38
+ headers = {
39
+ "host": self.endpoint.split("://", 1)[1].strip("/"),
40
+ "content-type": self.content_type,
41
+ "x-acs-action": action,
42
+ "x-acs-content-sha256": content_hash,
43
+ "x-acs-date": strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()),
44
+ "x-acs-signature-nonce": str(hash(time()))[2:],
45
+ "x-acs-version": self.api_version,
46
+ }
47
+
48
+ # 使用通用签名函数
49
+ authorization = hmac_sha256_authorization(
50
+ secret_key=self.token,
51
+ method=method,
52
+ path=path,
53
+ query=query_string,
54
+ headers=headers,
55
+ body_hash=content_hash,
56
+ signing_string_format="ACS3-HMAC-SHA256\n{HashedCanonicalRequest}",
57
+ authorization_format=(
58
+ "ACS3-HMAC-SHA256 Credential=" + self.id + ",SignedHeaders={SignedHeaders},Signature={Signature}"
59
+ ),
60
+ )
61
+ headers["Authorization"] = authorization
62
+ # 对于v3签名的RPC API,参数在request body中
63
+ path = path if not query_string else path + "?" + format(query_string)
64
+ return self._http(method, path, body=body_content, headers=headers)
65
+
66
+
67
+ class AlidnsProvider(AliBaseProvider):
68
+ """阿里云DNS Provider"""
69
+
70
+ def _split_zone_and_sub(self, domain):
71
+ # type: (str) -> tuple[str | None, str | None, str]
72
+ """
73
+ AliDNS 支持直接查询主域名和RR,无需循环查询。
74
+ 返回没有DomainId,用DomainName代替
75
+ https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-getmaindomainname
76
+ """
77
+ res = self._request("GetMainDomainName", InputString=domain)
78
+ sub, main = res.get("RR"), res.get("DomainName")
79
+ return (main, sub, main or domain)
80
+
81
+ def _query_zone_id(self, domain):
82
+ """调用_split_zone_and_sub可直接获取,无需调用_query_zone_id"""
83
+ raise NotImplementedError("_split_zone_and_sub is used to get zone_id")
84
+
85
+ def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
86
+ """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-describesubdomainrecords"""
87
+ sub = join_domain(subdomain, main_domain)
88
+ data = self._request(
89
+ "DescribeSubDomainRecords",
90
+ SubDomain=sub, # aliyun API要求SubDomain为完整域名
91
+ DomainName=main_domain,
92
+ Type=record_type,
93
+ Line=line,
94
+ PageSize=500,
95
+ Lang=extra.get("Lang"), # 默认中文
96
+ Status=extra.get("Status"), # 默认全部状态
97
+ )
98
+ records = data.get("DomainRecords", {}).get("Record", [])
99
+ if not records:
100
+ self.logger.warning(
101
+ "No records found for [%s] with %s <%s> (line: %s)", zone_id, subdomain, record_type, line
102
+ )
103
+ elif not isinstance(records, list):
104
+ self.logger.error("Invalid records format: %s", records)
181
105
  else:
182
- result = domain + " created fail!"
183
- return result
106
+ return next((r for r in records), None)
107
+ return None
108
+
109
+ def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
110
+ """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-adddomainrecord"""
111
+ data = self._request(
112
+ "AddDomainRecord",
113
+ DomainName=main_domain,
114
+ RR=subdomain,
115
+ Value=value,
116
+ Type=record_type,
117
+ TTL=ttl,
118
+ Line=line,
119
+ **extra
120
+ ) # fmt: skip
121
+ if data and data.get("RecordId"):
122
+ self.logger.info("Record created: %s", data)
123
+ return True
124
+ self.logger.error("Failed to create record: %s", data)
125
+ return False
126
+
127
+ def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
128
+ """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-updatedomainrecord"""
129
+ # 阿里云DNS update新旧值不能一样,先判断是否发生变化
130
+ if (
131
+ old_record.get("Value") == value
132
+ and old_record.get("Type") == record_type
133
+ and (not ttl or old_record.get("TTL") == ttl)
134
+ ):
135
+ domain = join_domain(old_record.get("RR"), old_record.get("DomainName"))
136
+ self.logger.warning("No changes detected, skipping update for record: %s", domain)
137
+ return True
138
+ data = self._request(
139
+ "UpdateDomainRecord",
140
+ RecordId=old_record.get("RecordId"),
141
+ Value=value,
142
+ RR=old_record.get("RR"),
143
+ Type=record_type,
144
+ TTL=ttl,
145
+ Line=line or old_record.get("Line"),
146
+ **extra
147
+ ) # fmt: skip
148
+ if data and data.get("RecordId"):
149
+ self.logger.info("Record updated: %s", data)
150
+ return True
151
+ self.logger.error("Failed to update record: %s", data)
152
+ return False
@@ -0,0 +1,130 @@
1
+ # coding=utf-8
2
+ """
3
+ AliESA API
4
+ 阿里云边缘安全加速(ESA) DNS 解析操作库
5
+ @author: NewFuture, GitHub Copilot
6
+ """
7
+
8
+ from time import strftime
9
+
10
+ from ._base import TYPE_JSON, join_domain
11
+ from .alidns import AliBaseProvider
12
+
13
+
14
+ class AliesaProvider(AliBaseProvider):
15
+ """阿里云边缘安全加速(ESA) DNS Provider"""
16
+
17
+ endpoint = "https://esa.cn-hangzhou.aliyuncs.com"
18
+ api_version = "2024-09-10" # ESA API版本
19
+ content_type = TYPE_JSON
20
+ remark = "Managed by DDNS %s" % strftime("%Y-%m-%d %H:%M:%S")
21
+
22
+ def _query_zone_id(self, domain):
23
+ # type: (str) -> str | None
24
+ """
25
+ 查询站点ID
26
+ https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listsites
27
+ """
28
+ res = self._request(method="GET", action="ListSites", SiteName=domain, PageSize=500)
29
+ sites = res.get("Sites", [])
30
+
31
+ for site in sites:
32
+ if site.get("SiteName") == domain:
33
+ site_id = site.get("SiteId")
34
+ self.logger.debug("Found site ID %s for domain %s", site_id, domain)
35
+ return site_id
36
+
37
+ self.logger.error("Site not found for domain: %s", domain)
38
+ return None
39
+
40
+ def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
41
+ # type: (str, str, str, str, str | None, dict) -> dict | None
42
+ """
43
+ 查询DNS记录
44
+ https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listrecords
45
+ """
46
+ full_domain = join_domain(subdomain, main_domain)
47
+ res = self._request(
48
+ method="GET",
49
+ action="ListRecords",
50
+ SiteId=int(zone_id),
51
+ RecordName=full_domain,
52
+ Type=self._get_type(record_type),
53
+ RecordMatchType="exact", # 精确匹配
54
+ PageSize=100,
55
+ )
56
+
57
+ records = res.get("Records", [])
58
+ if len(records) == 0:
59
+ self.logger.warning("No records found for [%s] with %s <%s>", zone_id, full_domain, record_type)
60
+ return None
61
+
62
+ # 返回第一个匹配的记录
63
+ record = records[0]
64
+ self.logger.debug("Found record: %s", record)
65
+ return record
66
+
67
+ def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
68
+ # type: (str, str, str, str, str, int | str | None, str | None, dict) -> bool
69
+ """
70
+ 创建DNS记录
71
+ https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord
72
+ """
73
+ full_domain = join_domain(subdomain, main_domain)
74
+ extra["Comment"] = extra.get("Comment", self.remark)
75
+ extra["BizName"] = extra.get("BizName", "web")
76
+ extra["Proxied"] = extra.get("Proxied", True)
77
+ data = self._request(
78
+ method="POST",
79
+ action="CreateRecord",
80
+ SiteId=int(zone_id),
81
+ RecordName=full_domain,
82
+ Type=self._get_type(record_type),
83
+ Data={"Value": value},
84
+ Ttl=ttl or 1,
85
+ **extra
86
+ ) # fmt: skip
87
+
88
+ if data and data.get("RecordId"):
89
+ self.logger.info("Record created: %s", data)
90
+ return True
91
+
92
+ self.logger.error("Failed to create record: %s", data)
93
+ return False
94
+
95
+ def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
96
+ # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
97
+ """
98
+ 更新DNS记录
99
+ https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-updaterecord
100
+ """
101
+ # 检查是否需要更新
102
+ if (
103
+ old_record.get("Data", {}).get("Value") == value
104
+ and old_record.get("RecordType") == self._get_type(record_type)
105
+ and (not ttl or old_record.get("Ttl") == ttl)
106
+ ):
107
+ self.logger.warning("No changes detected, skipping update for record: %s", old_record.get("RecordName"))
108
+ return True
109
+
110
+ extra["Comment"] = extra.get("Comment", self.remark)
111
+ extra["Proxied"] = extra.get("Proxied", old_record.get("Proxied"))
112
+ data = self._request(
113
+ method="POST",
114
+ action="UpdateRecord",
115
+ RecordId=old_record.get("RecordId"),
116
+ Data={"Value": value},
117
+ Ttl=ttl,
118
+ **extra
119
+ ) # fmt: skip
120
+
121
+ if data and data.get("RecordId"):
122
+ self.logger.info("Record updated: %s", data)
123
+ return True
124
+
125
+ self.logger.error("Failed to update record: %s", data)
126
+ return False
127
+
128
+ def _get_type(self, record_type):
129
+ # type: (str) -> str
130
+ return "A/AAAA" if record_type in ("A", "AAAA") else record_type