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.

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
8
 
9
- from json import loads as jsondecode
10
- from logging import debug, info, warning
9
+ from ._base import TYPE_JSON, SimpleProvider
11
10
  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
11
+ from json import loads as jsondecode
87
12
 
88
13
 
89
- def update_record(domain, value, record_type="A"):
14
+ class CallbackProvider(SimpleProvider):
90
15
  """
91
- 更新记录
16
+ 通用自定义回调 Provider,支持 GET/POST 任意接口。
17
+ Generic custom callback provider, supports GET/POST arbitrary API.
92
18
  """
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
19
 
117
- return result
20
+ endpoint = "" # CallbackProvider uses id as URL, no fixed API endpoint
21
+ content_type = TYPE_JSON
22
+ decode_response = False # Callback response is not JSON, it's a custom response
23
+
24
+ def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
25
+ """
26
+ 发送自定义回调请求,支持 GET/POST
27
+ Send custom callback request, support GET/POST
28
+ """
29
+ self.logger.info("%s => %s(%s)", domain, value, record_type)
30
+ url = self.id # 直接用 id 作为 url
31
+ token = self.token # token 作为 POST 参数
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)
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 id as URL, not as regular ID
76
+ if self.endpoint or (not self.id or "://" not in self.id):
77
+ # 如果 endpoint 已经设置,或者 id 不是有效的 URL,则抛出异常
78
+ self.logger.critical("endpoint [%s] or id [%s] 必须是有效的URL", self.endpoint, self.id)
79
+ raise ValueError("endpoint or id must be configured with URL")
@@ -1,163 +1,99 @@
1
1
  # coding=utf-8
2
2
  """
3
3
  CloudFlare API
4
- CloudFlare 接口解析操作库
5
- https://api.cloudflare.com/#dns-records-for-a-zone-properties
6
- @author: TongYifan
4
+ @author: TongYifan, NewFuture
7
5
  """
8
6
 
9
- from json import loads as jsondecode, dumps as jsonencode
10
- from logging import debug, info, warning
7
+ from ._base import TYPE_JSON, BaseProvider, join_domain
11
8
 
12
- try: # python 3
13
- from http.client import HTTPSConnection
14
- from urllib.parse import urlencode
15
- except ImportError: # python 2
16
- from httplib import HTTPSConnection
17
- from urllib import urlencode
18
9
 
19
- __author__ = 'TongYifan'
10
+ class CloudflareProvider(BaseProvider):
11
+ endpoint = "https://api.cloudflare.com"
12
+ content_type = TYPE_JSON
20
13
 
14
+ def _validate(self):
15
+ if not self.token:
16
+ raise ValueError("token must be configured")
17
+ if self.id:
18
+ # must be email for Cloudflare API v4
19
+ if "@" not in self.id:
20
+ self.logger.critical("ID 必须为空或有效的邮箱地址")
21
+ raise ValueError("ID must be a valid email or Empty for Cloudflare API v4")
21
22
 
22
- class Config:
23
- ID = "AUTH EMAIL" # CloudFlare 验证的是用户Email,等同于其他平台的userID
24
- TOKEN = "API KEY"
25
- PROXY = None # 代理设置
26
- TTL = None
27
-
28
-
29
- class API:
30
- # API 配置
31
- SITE = "api.cloudflare.com" # API endpoint
32
-
33
-
34
- def request(method, action, param=None, **params):
35
- """
36
- 发送请求数据
37
- """
38
- if param:
39
- params.update(param)
40
-
41
- params = dict((k, params[k]) for k in params if params[k] is not None)
42
- info("%s/%s : %s", API.SITE, action, params)
43
- if Config.PROXY:
44
- conn = HTTPSConnection(Config.PROXY)
45
- conn.set_tunnel(API.SITE, 443)
46
- else:
47
- conn = HTTPSConnection(API.SITE)
48
-
49
- if method in ['PUT', 'POST', 'PATCH']:
50
- # 从public_v(4,6)获取的IP是bytes类型,在json.dumps时会报TypeError
51
- params['content'] = str(params.get('content'))
52
- params = jsonencode(params)
53
- else: # (GET, DELETE) where DELETE doesn't require params in Cloudflare
54
- if params:
55
- action += '?' + urlencode(params)
56
- params = None
57
- if not Config.ID:
58
- headers = {"Content-type": "application/json",
59
- "Authorization": "Bearer " + Config.TOKEN}
60
- else:
61
- headers = {"Content-type": "application/json",
62
- "X-Auth-Email": Config.ID, "X-Auth-Key": Config.TOKEN}
63
- conn.request(method, '/client/v4/zones' + action, params, headers)
64
- response = conn.getresponse()
65
- res = response.read().decode('utf8')
66
- conn.close()
67
- if response.status < 200 or response.status >= 300:
68
- warning('%s : error[%d]:%s', action, response.status, res)
69
- raise Exception(res)
70
- else:
71
- data = jsondecode(res)
72
- debug('%s : result:%s', action, data)
73
- if not data:
74
- raise Exception("Empty Response")
75
- elif data.get('success'):
76
- return data.get('result', [{}])
23
+ def _request(self, method, action, **params):
24
+ """发送请求数据"""
25
+ headers = {}
26
+ if self.id:
27
+ headers["X-Auth-Email"] = self.id
28
+ headers["X-Auth-Key"] = self.token
77
29
  else:
78
- raise Exception(data.get('errors', [{}]))
79
-
80
-
81
- def get_zone_id(domain):
82
- """
83
- 切割域名获取主域名ID(Zone_ID)
84
- https://api.cloudflare.com/#zone-list-zones
85
- """
86
- zoneid = None
87
- domain_slice = domain.split('.')
88
- index = 2
89
- # ddns.example.com => example.com; ddns.example.eu.org => example.eu.org
90
- while (not zoneid) and (index <= len(domain_slice)):
91
- zones = request('GET', '', name='.'.join(domain_slice[-index:]))
92
- zone = next((z for z in zones if domain.endswith(z.get('name'))), None)
93
- zoneid = zone and zone['id']
94
- index += 1
95
- return zoneid
96
-
30
+ headers["Authorization"] = "Bearer " + self.token
97
31
 
98
- def get_records(zoneid, **conditions):
99
- """
100
- 获取记录ID
101
- 返回满足条件的所有记录[]
102
- TODO 大于100翻页
103
- """
104
- cache_key = zoneid + "_" + \
105
- conditions.get('name', "") + "_" + conditions.get('type', "")
106
- if not hasattr(get_records, 'records'):
107
- get_records.records = {} # "静态变量"存储已查询过的id
108
- get_records.keys = ('id', 'type', 'name', 'content', 'proxied', 'ttl')
109
-
110
- if zoneid not in get_records.records:
111
- get_records.records[cache_key] = {}
112
- data = request('GET', '/' + zoneid + '/dns_records',
113
- per_page=100, **conditions)
114
- if data:
115
- for record in data:
116
- get_records.records[cache_key][record['id']] = {
117
- k: v for (k, v) in record.items() if k in get_records.keys}
118
-
119
- records = {}
120
- for (zid, record) in get_records.records[cache_key].items():
121
- for (k, value) in conditions.items():
122
- if record.get(k) != value:
123
- break
124
- else: # for else push
125
- records[zid] = record
126
- return records
127
-
128
-
129
- def update_record(domain, value, record_type="A"):
130
- """
131
- 更新记录
132
- """
133
- info(">>>>>%s(%s)", domain, record_type)
134
- zoneid = get_zone_id(domain)
135
- if not zoneid:
136
- raise Exception("invalid domain: [ %s ] " % domain)
137
-
138
- records = get_records(zoneid, name=domain, type=record_type)
139
- cache_key = zoneid + "_" + domain + "_" + record_type
140
- result = {}
141
- if records: # update
142
- # https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record
143
- for (rid, record) in records.items():
144
- if record['content'] != value:
145
- res = request('PUT', '/' + zoneid + '/dns_records/' + record['id'],
146
- type=record_type, content=value, name=domain, proxied=record['proxied'], ttl=Config.TTL)
147
- if res:
148
- get_records.records[cache_key][rid]['content'] = value
149
- result[rid] = res.get("name")
150
- else:
151
- result[rid] = "Update fail!\n" + str(res)
152
- else:
153
- result[rid] = domain
154
- else: # create
155
- # https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record
156
- res = request('POST', '/' + zoneid + '/dns_records',
157
- type=record_type, name=domain, content=value, proxied=False, ttl=Config.TTL)
158
- if res:
159
- get_records.records[cache_key][res['id']] = res
160
- result = res
32
+ params = {k: v for k, v in params.items() if v is not None} # 过滤掉None参数
33
+ data = self._http(method, "/client/v4/zones" + action, headers=headers, params=params)
34
+ if data and data.get("success"):
35
+ return data.get("result") # 返回结果或原始数据
161
36
  else:
162
- result = domain + " created fail!"
163
- return result
37
+ self.logger.warning("Cloudflare API error: %s", data.get("errors", "Unknown error"))
38
+ return data
39
+
40
+ def _query_zone_id(self, domain):
41
+ """https://developers.cloudflare.com/api/resources/zones/methods/list/"""
42
+ params = {"name.exact": domain, "per_page": 50}
43
+ zones = self._request("GET", "", **params)
44
+ zone = next((z for z in zones if domain == z.get("name", "")), None)
45
+ self.logger.debug("Queried zone: %s", zone)
46
+ if zone:
47
+ return zone["id"]
48
+ return None
49
+
50
+ def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
51
+ # type: (str, str, str, str, str | None, dict) -> dict | None
52
+ """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/"""
53
+ # cloudflare的域名查询需要完整域名
54
+ name = join_domain(subdomain, main_domain)
55
+ query = {"name.exact": name} # type: dict[str, str|None]
56
+ if extra:
57
+ query["proxied"] = extra.get("proxied", None) # 代理状态
58
+ data = self._request("GET", "/{}/dns_records".format(zone_id), type=record_type, per_page=10000, **query)
59
+ record = next((r for r in data if r.get("name") == name and r.get("type") == record_type), None)
60
+ self.logger.debug("Record queried: %s", record)
61
+ if record:
62
+ return record
63
+ self.logger.warning("Failed to query record: %s", data)
64
+ return None
65
+
66
+ def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
67
+ # type: (str, str, str, str, str, int | str | None, str | None, dict ) -> bool
68
+ """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/"""
69
+ name = join_domain(subdomain, main_domain)
70
+ extra["comment"] = extra.get("comment", self.remark) # 添加注释
71
+ data = self._request(
72
+ "POST", "/{}/dns_records".format(zone_id), name=name, type=record_type, content=value, ttl=ttl, **extra
73
+ )
74
+ if data:
75
+ self.logger.info("Record created: %s", data)
76
+ return True
77
+ self.logger.error("Failed to create record: %s", data)
78
+ return False
79
+
80
+ def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
81
+ # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
82
+ """https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/"""
83
+ extra["comment"] = extra.get("comment", self.remark) # 注释
84
+ extra["proxied"] = old_record.get("proxied", extra.get("proxied")) # 保持原有的代理状态
85
+ extra["tags"] = old_record.get("tags", extra.get("tags")) # 保持原有的标签
86
+ extra["settings"] = old_record.get("settings", extra.get("settings")) # 保持原有的设置
87
+ data = self._request(
88
+ "PUT",
89
+ "/{}/dns_records/{}".format(zone_id, old_record["id"]),
90
+ type=record_type,
91
+ name=old_record.get("name"),
92
+ content=value,
93
+ ttl=ttl,
94
+ **extra
95
+ ) # fmt: skip
96
+ self.logger.debug("Record updated: %s", data)
97
+ if data:
98
+ return True
99
+ return False
ddns/provider/debug.py ADDED
@@ -0,0 +1,19 @@
1
+ # coding=utf-8
2
+ """
3
+ DebugProvider
4
+ 仅打印出 IP 地址,不进行任何实际 DNS 更新。
5
+ """
6
+
7
+ from ._base import SimpleProvider
8
+
9
+
10
+ class DebugProvider(SimpleProvider):
11
+ def _validate(self):
12
+ """无需任何验证"""
13
+ pass
14
+
15
+ def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
16
+ self.logger.debug("DebugProvider: %s(%s) => %s", domain, record_type, value)
17
+ ip_type = "IPv4" if record_type == "A" else "IPv6" if record_type == "AAAA" else record_type
18
+ print("[{}] {}".format(ip_type, value))
19
+ return True