ddns 4.0.1__py2.py3-none-any.whl → 4.1.0b1__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.

ddns/provider/alidns.py CHANGED
@@ -2,182 +2,133 @@
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 ._base import TYPE_FORM, BaseProvider, hmac_sha256_authorization, sha256_hash, join_domain
9
+ from time import strftime, gmtime, time
10
+
11
+
12
+ class AlidnsProvider(BaseProvider):
13
+ API = "https://alidns.aliyuncs.com"
14
+ content_type = TYPE_FORM # 阿里云DNS API使用表单格式
15
+
16
+ api_version = "2015-01-09" # API版本,v3签名需要
17
+
18
+ def _request(self, action, **params):
19
+ # type: (str, **(str | int | bytes | bool | None)) -> dict
20
+ """Aliyun v3 https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature"""
21
+ params = {k: v for k, v in params.items() if v is not None}
22
+ body_content = self._encode(params) if len(params) > 0 else ""
23
+ content_hash = sha256_hash(body_content)
24
+ # 构造请求头部
25
+ headers = {
26
+ "host": self.API.split("://", 1)[1].strip("/"),
27
+ "content-type": self.content_type,
28
+ "x-acs-action": action,
29
+ "x-acs-content-sha256": content_hash,
30
+ "x-acs-date": strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()),
31
+ "x-acs-signature-nonce": str(hash(time()))[2:],
32
+ "x-acs-version": self.api_version,
33
+ }
34
+
35
+ # 使用通用签名函数
36
+ authorization = hmac_sha256_authorization(
37
+ secret_key=self.auth_token,
38
+ method="POST",
39
+ path="/",
40
+ query="",
41
+ headers=headers,
42
+ body_hash=content_hash,
43
+ signing_string_format="ACS3-HMAC-SHA256\n{HashedCanonicalRequest}",
44
+ authorization_format=(
45
+ "ACS3-HMAC-SHA256 Credential=" + self.auth_id + ",SignedHeaders={SignedHeaders},Signature={Signature}"
46
+ ),
47
+ )
48
+ headers["Authorization"] = authorization
49
+ # 对于v3签名的RPC API,参数在request body中
50
+ return self._http("POST", "/", body=body_content, headers=headers)
51
+
52
+ def _split_zone_and_sub(self, domain):
53
+ # type: (str) -> tuple[str | None, str | None, str]
54
+ """
55
+ AliDNS 支持直接查询主域名和RR,无需循环查询。
56
+ 返回没有DomainId,用DomainName代替
57
+ https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-getmaindomainname
58
+ """
59
+ res = self._request("GetMainDomainName", InputString=domain)
60
+ sub, main = res.get("RR"), res.get("DomainName")
61
+ return (main, sub, main or domain)
62
+
63
+ def _query_zone_id(self, domain):
64
+ """调用_split_zone_and_sub可直接获取,无需调用_query_zone_id"""
65
+ raise NotImplementedError("_split_zone_and_sub is used to get zone_id")
66
+
67
+ def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
68
+ """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-describesubdomainrecords"""
69
+ sub = join_domain(subdomain, main_domain)
70
+ data = self._request(
71
+ "DescribeSubDomainRecords",
72
+ SubDomain=sub, # aliyun API要求SubDomain为完整域名
73
+ DomainName=main_domain,
74
+ Type=record_type,
75
+ Line=line,
76
+ PageSize=500,
77
+ Lang=extra.get("Lang"), # 默认中文
78
+ Status=extra.get("Status"), # 默认全部状态
79
+ )
80
+ records = data.get("DomainRecords", {}).get("Record", [])
81
+ if not records:
82
+ self.logger.warning(
83
+ "No records found for [%s] with %s <%s> (line: %s)", zone_id, subdomain, record_type, line
84
+ )
85
+ elif not isinstance(records, list):
86
+ self.logger.error("Invalid records format: %s", records)
181
87
  else:
182
- result = domain + " created fail!"
183
- return result
88
+ return next((r for r in records), None)
89
+ return None
90
+
91
+ def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
92
+ """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-adddomainrecord"""
93
+ data = self._request(
94
+ "AddDomainRecord",
95
+ DomainName=main_domain,
96
+ RR=subdomain,
97
+ Value=value,
98
+ Type=record_type,
99
+ TTL=ttl,
100
+ Line=line,
101
+ **extra
102
+ )
103
+ if data and data.get("RecordId"):
104
+ self.logger.info("Record created: %s", data)
105
+ return True
106
+ self.logger.error("Failed to create record: %s", data)
107
+ return False
108
+
109
+ def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
110
+ """https://help.aliyun.com/zh/dns/api-alidns-2015-01-09-updatedomainrecord"""
111
+ # 阿里云DNS update新旧值不能一样,先判断是否发生变化
112
+ if (
113
+ old_record.get("Value") == value
114
+ and old_record.get("Type") == record_type
115
+ and (not ttl or old_record.get("TTL") == ttl)
116
+ ):
117
+ domain = join_domain(old_record.get("RR"), old_record.get("DomainName"))
118
+ self.logger.warning("No changes detected, skipping update for record: %s", domain)
119
+ return True
120
+ data = self._request(
121
+ "UpdateDomainRecord",
122
+ RecordId=old_record.get("RecordId"),
123
+ Value=value,
124
+ RR=old_record.get("RR"),
125
+ Type=record_type,
126
+ TTL=ttl,
127
+ Line=line or old_record.get("Line"),
128
+ **extra
129
+ )
130
+ if data and data.get("RecordId"):
131
+ self.logger.info("Record updated: %s", data)
132
+ return True
133
+ self.logger.error("Failed to update record: %s", data)
134
+ return False
ddns/provider/callback.py CHANGED
@@ -3,115 +3,77 @@
3
3
  Custom Callback API
4
4
  自定义回调接口解析操作库
5
5
 
6
- @author: 老周部落
6
+ @author: 老周部落, NewFuture
7
7
  """
8
-
9
- from json import loads as jsondecode
10
- from logging import debug, info, warning
8
+ from ._base import TYPE_JSON, SimpleProvider
11
9
  from time import time
12
-
13
- try: # python 3
14
- from http.client import HTTPSConnection, HTTPConnection
15
- from urllib.parse import urlencode, urlparse, parse_qsl
16
- except ImportError: # python 2
17
- from httplib import HTTPSConnection, HTTPConnection
18
- from urlparse import urlparse, parse_qsl
19
- from urllib import urlencode
20
-
21
- __author__ = '老周部落'
22
-
23
-
24
- class Config:
25
- ID = None # 自定义回调 URL
26
- TOKEN = None # 使用 JSON 编码的 POST 参数
27
- PROXY = None # 代理设置
28
- TTL = None
29
-
30
-
31
- def request(method, action, param=None, **params):
32
- """
33
- 发送请求数据
34
- """
35
- if param:
36
- params.update(param)
37
-
38
- URLObj = urlparse(Config.ID)
39
- params = dict((k, params[k]) for k in params if params[k] is not None)
40
- info("%s/%s : %s", URLObj.netloc, action, params)
41
-
42
- if Config.PROXY:
43
- if URLObj.netloc == "http":
44
- conn = HTTPConnection(Config.PROXY)
45
- else:
46
- conn = HTTPSConnection(Config.PROXY)
47
- conn.set_tunnel(URLObj.netloc, URLObj.port)
48
- else:
49
- if URLObj.netloc == "http":
50
- conn = HTTPConnection(URLObj.netloc, URLObj.port)
51
- else:
52
- conn = HTTPSConnection(URLObj.netloc, URLObj.port)
53
-
54
- headers = {}
55
-
56
- if method == "GET":
57
- if params:
58
- action += '?' + urlencode(params)
59
- params = ""
60
- else:
61
- headers["Content-Type"] = "application/x-www-form-urlencoded"
62
-
63
- params = urlencode(params)
64
-
65
- conn.request(method, action, params, headers)
66
- response = conn.getresponse()
67
- res = response.read().decode('utf8')
68
- conn.close()
69
- if response.status < 200 or response.status >= 300:
70
- warning('%s : error[%d]:%s', action, response.status, res)
71
- raise Exception(res)
72
- else:
73
- debug('%s : result:%s', action, res)
74
- return res
75
-
76
-
77
- def replace_params(domain, record_type, ip, params):
78
- """
79
- 替换定义常量为实际值
80
- """
81
- dict = {"__DOMAIN__": domain, "__RECORDTYPE__": record_type,
82
- "__TTL__": Config.TTL, "__TIMESTAMP__": time(), "__IP__": ip}
83
- for key, value in params.items():
84
- if dict.get(value):
85
- params[key] = dict.get(value)
86
- return params
10
+ from json import loads as jsondecode
87
11
 
88
12
 
89
- def update_record(domain, value, record_type="A"):
13
+ class CallbackProvider(SimpleProvider):
90
14
  """
91
- 更新记录
15
+ 通用自定义回调 Provider,支持 GET/POST 任意接口。
16
+ Generic custom callback provider, supports GET/POST arbitrary API.
92
17
  """
93
- info(">>>>>%s(%s)", domain, record_type)
94
-
95
- result = {}
96
-
97
- if not Config.TOKEN: # 此处使用 TOKEN 参数透传 POST 参数所用的 JSON
98
- method = "GET"
99
- URLObj = urlparse(Config.ID)
100
- path = URLObj.path
101
- query = dict(parse_qsl(URLObj.query))
102
- params = replace_params(domain, record_type, value, query)
103
- else:
104
- method = "POST"
105
- URLObj = urlparse(Config.ID)
106
- path = URLObj.path
107
- params = replace_params(domain, record_type,
108
- value, jsondecode(Config.TOKEN))
109
-
110
- res = request(method, path, params)
111
-
112
- if res:
113
- result = "Callback Request Success!\n" + res
114
- else:
115
- result = "Callback Request Fail!\n"
116
18
 
117
- return result
19
+ API = "" # CallbackProvider uses auth_id as URL, no fixed API endpoint
20
+ content_type = TYPE_JSON
21
+ decode_response = False # Callback response is not JSON, it's a custom response
22
+
23
+ def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
24
+ """
25
+ 发送自定义回调请求,支持 GET/POST
26
+ Send custom callback request, support GET/POST
27
+ """
28
+ self.logger.info("%s => %s(%s)", domain, value, record_type)
29
+ url = self.auth_id # 直接用 auth_id 作为 url
30
+ token = self.auth_token # auth_token 作为 POST 参数
31
+ headers = {"User-Agent": "DDNS/{0} (ddns@newfuture.cc)".format(self.version)}
32
+ extra.update(
33
+ {
34
+ "__DOMAIN__": domain,
35
+ "__RECORDTYPE__": record_type,
36
+ "__TTL__": ttl,
37
+ "__IP__": value,
38
+ "__TIMESTAMP__": time(),
39
+ "__LINE__": line,
40
+ }
41
+ )
42
+ url = self._replace_vars(url, extra)
43
+ method, params = "GET", None
44
+ if token:
45
+ # 如果有 token,使用 POST 方法
46
+ method = "POST"
47
+ # POST 方式,token 作为 POST 参数
48
+ params = token if isinstance(token, dict) else jsondecode(token)
49
+ for k, v in params.items():
50
+ if hasattr(v, "replace"): # 判断是否支持字符串替换, 兼容py2,py3
51
+ params[k] = self._replace_vars(v, extra)
52
+
53
+ try:
54
+ res = self._http(method, url, body=params, headers=headers)
55
+ if res is not None:
56
+ self.logger.info("Callback result: %s", res)
57
+ return True
58
+ else:
59
+ self.logger.warning("Callback received empty response.")
60
+ except Exception as e:
61
+ self.logger.error("Callback failed: %s", e)
62
+ return False
63
+
64
+ def _replace_vars(self, string, mapping):
65
+ # type: (str, dict) -> str
66
+ """
67
+ 替换字符串中的变量为实际值
68
+ Replace variables in string with actual values
69
+ """
70
+ for k, v in mapping.items():
71
+ string = string.replace(k, str(v))
72
+ return string
73
+
74
+ def _validate(self):
75
+ # CallbackProvider uses auth_id as URL, not as regular ID
76
+ if not self.auth_id or "://" not in self.auth_id:
77
+ self.logger.critical("callback ID 参数[%s] 必须是有效的URL", self.auth_id)
78
+ raise ValueError("id must be configured with URL")
79
+ # CallbackProvider doesn't need auth_token validation (it can be empty)