ddns 4.1.0b1__py2.py3-none-any.whl → 4.1.0b3__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/__builtins__.pyi +1 -0
- ddns/__init__.py +2 -11
- ddns/__main__.py +91 -138
- ddns/{util/cache.py → cache.py} +42 -11
- ddns/{util/ip.py → ip.py} +11 -11
- ddns/provider/__init__.py +24 -4
- ddns/provider/_base.py +65 -198
- ddns/provider/_signature.py +113 -0
- ddns/provider/alidns.py +35 -17
- ddns/provider/aliesa.py +130 -0
- ddns/provider/callback.py +10 -10
- ddns/provider/cloudflare.py +10 -10
- ddns/provider/debug.py +0 -1
- ddns/provider/dnscom.py +7 -6
- ddns/provider/dnspod.py +4 -7
- ddns/provider/dnspod_com.py +1 -1
- ddns/provider/edgeone.py +83 -0
- ddns/provider/he.py +4 -4
- ddns/provider/huaweidns.py +12 -11
- ddns/provider/namesilo.py +159 -0
- ddns/provider/noip.py +101 -0
- ddns/provider/tencentcloud.py +13 -14
- ddns/util/comment.py +88 -0
- ddns/util/fileio.py +113 -0
- ddns/util/http.py +275 -230
- {ddns-4.1.0b1.dist-info → ddns-4.1.0b3.dist-info}/METADATA +95 -119
- ddns-4.1.0b3.dist-info/RECORD +32 -0
- ddns/util/config.py +0 -314
- ddns-4.1.0b1.dist-info/RECORD +0 -26
- {ddns-4.1.0b1.dist-info → ddns-4.1.0b3.dist-info}/WHEEL +0 -0
- {ddns-4.1.0b1.dist-info → ddns-4.1.0b3.dist-info}/entry_points.txt +0 -0
- {ddns-4.1.0b1.dist-info → ddns-4.1.0b3.dist-info}/licenses/LICENSE +0 -0
- {ddns-4.1.0b1.dist-info → ddns-4.1.0b3.dist-info}/top_level.txt +0 -0
ddns/util/http.py
CHANGED
|
@@ -1,246 +1,149 @@
|
|
|
1
1
|
# coding=utf-8
|
|
2
2
|
"""
|
|
3
|
-
HTTP
|
|
4
|
-
|
|
5
|
-
HTTP utilities module for DDNS project.
|
|
6
|
-
Provides common HTTP functionality including redirect following support.
|
|
3
|
+
HTTP请求工具模块,SSL 代理,重试,basic-auth
|
|
4
|
+
HTTP utilities module, including ssl, proxies, retry and basicAuth.
|
|
7
5
|
|
|
8
6
|
@author: NewFuture
|
|
9
7
|
"""
|
|
10
8
|
|
|
11
9
|
from logging import getLogger
|
|
10
|
+
from re import compile
|
|
12
11
|
import ssl
|
|
13
12
|
import os
|
|
13
|
+
import time
|
|
14
|
+
import socket
|
|
15
|
+
|
|
16
|
+
from .. import __version__
|
|
14
17
|
|
|
15
18
|
try: # python 3
|
|
16
|
-
from
|
|
17
|
-
|
|
19
|
+
from urllib.request import (
|
|
20
|
+
BaseHandler,
|
|
21
|
+
build_opener,
|
|
22
|
+
HTTPBasicAuthHandler,
|
|
23
|
+
HTTPDefaultErrorHandler,
|
|
24
|
+
HTTPPasswordMgrWithDefaultRealm,
|
|
25
|
+
HTTPSHandler,
|
|
26
|
+
ProxyHandler,
|
|
27
|
+
Request,
|
|
28
|
+
)
|
|
29
|
+
from urllib.parse import quote, urlencode, unquote
|
|
30
|
+
from http.client import HTTPSConnection
|
|
18
31
|
except ImportError: # python 2
|
|
19
|
-
from
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
name_lower = name.lower()
|
|
58
|
-
for header_name, header_value in self.headers:
|
|
59
|
-
if header_name.lower() == name_lower:
|
|
60
|
-
return header_value
|
|
61
|
-
return default
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _create_connection(hostname, port, is_https, proxy, verify_ssl):
|
|
65
|
-
# type: (str, int | None, bool, str | None, bool | str) -> HTTPConnection | HTTPSConnection
|
|
66
|
-
"""创建HTTP/HTTPS连接"""
|
|
67
|
-
target = proxy or hostname
|
|
68
|
-
|
|
69
|
-
if not is_https:
|
|
70
|
-
conn = HTTPConnection(target, port)
|
|
71
|
-
else:
|
|
72
|
-
ssl_context = ssl.create_default_context()
|
|
73
|
-
|
|
74
|
-
if verify_ssl is False:
|
|
75
|
-
# 禁用SSL验证
|
|
76
|
-
ssl_context.check_hostname = False
|
|
77
|
-
ssl_context.verify_mode = ssl.CERT_NONE
|
|
78
|
-
elif hasattr(verify_ssl, "lower") and verify_ssl.lower() not in ("auto", "true"): # type: ignore[union-attr]
|
|
79
|
-
# 使用自定义CA证书 lower 判断 str/unicode 兼容 python2
|
|
80
|
-
try:
|
|
81
|
-
ssl_context.load_verify_locations(verify_ssl) # type: ignore[arg-type]
|
|
82
|
-
except Exception as e:
|
|
83
|
-
logger.error("Failed to load CA certificate from %s: %s", verify_ssl, e)
|
|
84
|
-
else:
|
|
85
|
-
# 默认验证,尝试加载系统证书
|
|
86
|
-
_load_system_ca_certs(ssl_context)
|
|
87
|
-
conn = HTTPSConnection(target, port, context=ssl_context)
|
|
88
|
-
|
|
89
|
-
# 设置代理隧道
|
|
90
|
-
if proxy:
|
|
91
|
-
conn.set_tunnel(hostname, port) # type: ignore[attr-defined]
|
|
92
|
-
|
|
93
|
-
return conn
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _load_system_ca_certs(ssl_context):
|
|
97
|
-
# type: (ssl.SSLContext) -> None
|
|
98
|
-
"""加载系统CA证书"""
|
|
99
|
-
# 常见CA证书路径
|
|
100
|
-
ca_paths = [
|
|
101
|
-
# Linux/Unix常用路径
|
|
102
|
-
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
|
103
|
-
"/etc/pki/tls/certs/ca-bundle.crt", # RedHat/CentOS
|
|
104
|
-
"/etc/ssl/ca-bundle.pem", # OpenSUSE
|
|
105
|
-
"/etc/ssl/cert.pem", # OpenBSD
|
|
106
|
-
"/usr/local/share/certs/ca-root-nss.crt", # FreeBSD
|
|
107
|
-
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", # Fedora/RHEL
|
|
108
|
-
# macOS路径
|
|
109
|
-
"/usr/local/etc/openssl/cert.pem", # macOS with Homebrew
|
|
110
|
-
"/opt/local/etc/openssl/cert.pem", # macOS with MacPorts
|
|
111
|
-
]
|
|
112
|
-
|
|
113
|
-
# Windows额外路径
|
|
114
|
-
if os.name == "nt":
|
|
115
|
-
ca_paths.append("C:\\Program Files\\Git\\mingw64\\ssl\\cert.pem")
|
|
116
|
-
ca_paths.append("C:\\Program Files\\OpenSSL\\ssl\\cert.pem")
|
|
117
|
-
|
|
118
|
-
loaded_count = 0
|
|
119
|
-
for ca_path in ca_paths:
|
|
120
|
-
if os.path.isfile(ca_path):
|
|
121
|
-
try:
|
|
122
|
-
ssl_context.load_verify_locations(ca_path)
|
|
123
|
-
loaded_count += 1
|
|
124
|
-
logger.debug("Loaded CA certificates from: %s", ca_path)
|
|
125
|
-
except Exception as e:
|
|
126
|
-
logger.debug("Failed to load CA certificates from %s: %s", ca_path, e)
|
|
127
|
-
|
|
128
|
-
if loaded_count > 0:
|
|
129
|
-
logger.debug("Successfully loaded CA certificates from %d locations", loaded_count)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def _close_connection(conn):
|
|
133
|
-
# type: (HTTPConnection | HTTPSConnection) -> None
|
|
134
|
-
"""关闭HTTP/HTTPS连接"""
|
|
135
|
-
try:
|
|
136
|
-
conn.close()
|
|
137
|
-
except Exception as e:
|
|
138
|
-
logger.warning("Failed to close connection: %s", e)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def send_http_request(method, url, body=None, headers=None, proxy=None, max_redirects=5, verify_ssl=True):
|
|
142
|
-
# type: (str, str, str | bytes | None, dict[str, str] | None, str | None, int, bool | str) -> HttpResponse
|
|
32
|
+
from urllib2 import ( # type: ignore[no-redef]
|
|
33
|
+
BaseHandler,
|
|
34
|
+
build_opener,
|
|
35
|
+
HTTPBasicAuthHandler,
|
|
36
|
+
HTTPDefaultErrorHandler,
|
|
37
|
+
HTTPPasswordMgrWithDefaultRealm,
|
|
38
|
+
HTTPSHandler,
|
|
39
|
+
ProxyHandler,
|
|
40
|
+
Request,
|
|
41
|
+
)
|
|
42
|
+
from urllib import urlencode, quote, unquote # type: ignore[no-redef]
|
|
43
|
+
from httplib import HTTPSConnection # type: ignore[no-redef]
|
|
44
|
+
|
|
45
|
+
__all__ = ["request", "HttpResponse", "quote", "urlencode", "USER_AGENT"]
|
|
46
|
+
# Default user-agent for DDNS requests
|
|
47
|
+
USER_AGENT = "DDNS/{} (ddns@newfuture.cc)".format(__version__ if __version__ != "${BUILD_VERSION}" else "dev")
|
|
48
|
+
|
|
49
|
+
logger = getLogger().getChild("http")
|
|
50
|
+
_AUTH_URL_RE = compile(r"^(https?://)([^:/?#]+):([^@]+)@(.+)$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _proxy_handler(proxy):
|
|
54
|
+
# type: (str | None) -> ProxyHandler | None
|
|
55
|
+
"""标准化代理格式并返回ProxyHandler对象"""
|
|
56
|
+
if not proxy or proxy.upper() in ("SYSTEM", "DEFAULT"):
|
|
57
|
+
return ProxyHandler() # 系统代理
|
|
58
|
+
elif proxy.upper() in ("DIRECT"):
|
|
59
|
+
return ProxyHandler({}) # 不使用代理
|
|
60
|
+
elif "://" not in proxy:
|
|
61
|
+
# 检查是否是 host:port 格式
|
|
62
|
+
logger.warning("Legacy proxy format '%s' detected, converting to 'http://%s'", proxy, proxy)
|
|
63
|
+
proxy = "http://" + proxy
|
|
64
|
+
|
|
65
|
+
return ProxyHandler({"http": proxy, "https": proxy})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def request(method, url, data=None, headers=None, proxies=None, verify=True, auth=None, retries=1):
|
|
69
|
+
# type: (str, str, str | bytes | None, dict[str, str] | None, list[str] | None, bool | str, BaseHandler | None, int) -> HttpResponse # noqa: E501
|
|
143
70
|
"""
|
|
144
|
-
发送HTTP/HTTPS
|
|
145
|
-
|
|
71
|
+
发送HTTP/HTTPS请求,支持自动重试和类似requests.request的参数接口
|
|
72
|
+
|
|
146
73
|
Args:
|
|
147
74
|
method (str): HTTP方法,如GET、POST等
|
|
148
|
-
url (str): 请求的URL
|
|
149
|
-
|
|
150
|
-
headers (dict[str, str] | None):
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
75
|
+
url (str): 请求的URL,支持嵌入式认证格式 https://user:pass@domain.com
|
|
76
|
+
data (str | bytes | None): 请求体数据
|
|
77
|
+
headers (dict[str, str] | None): 请求头字典
|
|
78
|
+
proxies (list[str | None] | None): 代理列表,支持以下格式:
|
|
79
|
+
- "http://host:port" - 具体代理地址
|
|
80
|
+
- "DIRECT" - 直连,不使用代理
|
|
81
|
+
- "SYSTEM" - 使用系统默认代理设置
|
|
82
|
+
verify (bool | str): SSL验证配置
|
|
83
|
+
- True: 启用标准SSL验证
|
|
84
|
+
- False: 禁用SSL验证
|
|
85
|
+
- "auto": 启用验证,失败时自动回退到不验证
|
|
86
|
+
- str: 自定义CA证书文件路径
|
|
87
|
+
auth (BaseHandler | None): 自定义认证处理器
|
|
88
|
+
retries (int): 最大重试次数,默认1次
|
|
89
|
+
|
|
154
90
|
Returns:
|
|
155
|
-
HttpResponse:
|
|
91
|
+
HttpResponse: 响应对象
|
|
92
|
+
|
|
156
93
|
Raises:
|
|
157
|
-
|
|
94
|
+
URLError: 如果请求失败
|
|
158
95
|
ssl.SSLError: 如果SSL验证失败
|
|
96
|
+
ValueError: 如果参数无效
|
|
159
97
|
"""
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
98
|
+
# 解析URL以检查是否包含嵌入式认证信息
|
|
99
|
+
m = _AUTH_URL_RE.match(url)
|
|
100
|
+
if m:
|
|
101
|
+
protocol, username, password, rest = m.groups()
|
|
102
|
+
url = protocol + rest
|
|
103
|
+
auth = HTTPBasicAuthHandler(HTTPPasswordMgrWithDefaultRealm())
|
|
104
|
+
auth.add_password(None, url, unquote(username), unquote(password)) # type: ignore[no-untyped-call]
|
|
105
|
+
|
|
106
|
+
# 准备请求
|
|
107
|
+
if isinstance(data, str):
|
|
108
|
+
data = data.encode("utf-8")
|
|
109
|
+
|
|
110
|
+
if headers is None:
|
|
111
|
+
headers = {}
|
|
112
|
+
if not any(k.lower() == "user-agent" for k in headers.keys()):
|
|
113
|
+
headers["User-Agent"] = USER_AGENT # 设置默认User-Agent
|
|
114
|
+
|
|
115
|
+
handlers = [NoHTTPErrorHandler(), AutoSSLHandler(verify), RetryHandler(retries)]
|
|
116
|
+
handlers += [auth] if auth else []
|
|
117
|
+
|
|
118
|
+
def run(proxy_handler):
|
|
119
|
+
req = Request(url, data=data, headers=headers)
|
|
120
|
+
req.get_method = lambda: method.upper() # python 2 兼容
|
|
121
|
+
h = handlers + ([proxy_handler] if proxy_handler else [])
|
|
122
|
+
return build_opener(*h).open(req, timeout=60 if method == "GET" else 120) # 创建处理器链
|
|
123
|
+
|
|
124
|
+
if not proxies:
|
|
125
|
+
response = run(None) # 默认
|
|
126
|
+
else:
|
|
127
|
+
last_err = None # type: Exception # type: ignore[assignment]
|
|
128
|
+
for p in proxies:
|
|
129
|
+
logger.debug("Trying proxy: %s", p)
|
|
130
|
+
try:
|
|
131
|
+
response = run(_proxy_handler(p)) # 尝试使用代理
|
|
132
|
+
break # 成功后退出循环
|
|
133
|
+
except Exception as e:
|
|
134
|
+
last_err = e
|
|
187
135
|
else:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
# 检查重定向
|
|
191
|
-
status = response.status
|
|
192
|
-
if 300 <= status < 400:
|
|
193
|
-
location = response.getheader("Location")
|
|
194
|
-
_close_connection(conn)
|
|
195
|
-
if not location:
|
|
196
|
-
# 无Location头的重定向
|
|
197
|
-
logger.warning("Redirect status %d but no Location header", status)
|
|
198
|
-
location = ""
|
|
199
|
-
|
|
200
|
-
# 构建重定向URL
|
|
201
|
-
redirect_url = _build_redirect_url(location, "{}://{}".format(url_obj.scheme, url_obj.netloc), url_obj.path)
|
|
202
|
-
|
|
203
|
-
# 如果重定向URL没有查询字符串,但原始URL有,则附加
|
|
204
|
-
if url_obj.query and "?" not in redirect_url:
|
|
205
|
-
redirect_url += "?" + url_obj.query
|
|
206
|
-
|
|
207
|
-
# 确定重定向方法:303或302+POST转为GET,其他保持原方法
|
|
208
|
-
if status == 303 or (status == 302 and method == "POST"):
|
|
209
|
-
method, body = "GET", None
|
|
210
|
-
# 如果从POST转为GET,移除相关的头部
|
|
211
|
-
if headers:
|
|
212
|
-
headers = {k: v for k, v in headers.items() if k.lower() not in ("content-length", "content-type")}
|
|
213
|
-
|
|
214
|
-
logger.info("Redirecting [%d] to: %s", status, redirect_url)
|
|
215
|
-
# 递归处理重定向
|
|
216
|
-
return send_http_request(method, redirect_url, body, headers, proxy, max_redirects - 1, actual_verify_ssl)
|
|
217
|
-
|
|
218
|
-
# 处理最终响应
|
|
219
|
-
content_type = response.getheader("Content-Type")
|
|
220
|
-
response_headers = response.getheaders()
|
|
221
|
-
raw_body = response.read()
|
|
222
|
-
_close_connection(conn)
|
|
223
|
-
|
|
224
|
-
# 解码响应体并创建响应对象
|
|
225
|
-
decoded_body = _decode_response_body(raw_body, content_type)
|
|
226
|
-
return HttpResponse(status, response.reason, response_headers, decoded_body)
|
|
227
|
-
|
|
136
|
+
logger.error("All proxies failed")
|
|
137
|
+
raise last_err # 如果所有代理都失败,抛出最后一个错误
|
|
228
138
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
139
|
+
# 处理响应
|
|
140
|
+
response_headers = response.info()
|
|
141
|
+
raw_body = response.read()
|
|
142
|
+
decoded_body = _decode_response_body(raw_body, response_headers.get("Content-Type"))
|
|
143
|
+
status_code = response.getcode()
|
|
144
|
+
reason = getattr(response, "msg", "")
|
|
234
145
|
|
|
235
|
-
|
|
236
|
-
# 绝对路径:使用base的scheme和netloc
|
|
237
|
-
base_url = urlparse(base)
|
|
238
|
-
return "{}://{}{}".format(base_url.scheme, base_url.netloc, location)
|
|
239
|
-
else:
|
|
240
|
-
base_path = path.rsplit("/", 1)[0] if "/" in path else ""
|
|
241
|
-
if not base_path.endswith("/"):
|
|
242
|
-
base_path += "/"
|
|
243
|
-
return base + base_path + location
|
|
146
|
+
return HttpResponse(status_code, reason, response_headers, decoded_body)
|
|
244
147
|
|
|
245
148
|
|
|
246
149
|
def _decode_response_body(raw_body, content_type):
|
|
@@ -257,14 +160,13 @@ def _decode_response_body(raw_body, content_type):
|
|
|
257
160
|
if end == -1:
|
|
258
161
|
end = len(content_type)
|
|
259
162
|
charset = content_type[start:end].strip("'\" ").lower()
|
|
163
|
+
|
|
164
|
+
# 处理常见别名映射
|
|
165
|
+
charset_aliases = {"gb2312": "gbk", "iso-8859-1": "latin-1"}
|
|
166
|
+
charset = charset_aliases.get(charset, charset)
|
|
167
|
+
if charset in charsets:
|
|
168
|
+
charsets.remove(charset)
|
|
260
169
|
charsets.insert(0, charset)
|
|
261
|
-
# 处理常见别名
|
|
262
|
-
if charset == "gb2312":
|
|
263
|
-
charsets.remove("gbk")
|
|
264
|
-
charsets.insert(0, "gbk")
|
|
265
|
-
elif charset == "iso-8859-1":
|
|
266
|
-
charsets.remove("latin-1")
|
|
267
|
-
charsets.insert(0, "latin-1")
|
|
268
170
|
|
|
269
171
|
# 按优先级尝试解码
|
|
270
172
|
for encoding in charsets:
|
|
@@ -273,5 +175,148 @@ def _decode_response_body(raw_body, content_type):
|
|
|
273
175
|
except (UnicodeDecodeError, LookupError):
|
|
274
176
|
continue
|
|
275
177
|
|
|
276
|
-
# 最终后备:UTF-8替换错误字符
|
|
277
|
-
|
|
178
|
+
return raw_body.decode("utf-8", errors="replace") # 最终后备:UTF-8替换错误字符
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class HttpResponse(object):
|
|
182
|
+
"""HTTP响应封装类"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, status, reason, headers, body):
|
|
185
|
+
# type: (int, str, Any, str) -> None
|
|
186
|
+
"""初始化HTTP响应对象"""
|
|
187
|
+
self.status = status
|
|
188
|
+
self.reason = reason
|
|
189
|
+
self.headers = headers
|
|
190
|
+
self.body = body
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class NoHTTPErrorHandler(HTTPDefaultErrorHandler): # type: ignore[misc]
|
|
194
|
+
"""自定义HTTP错误处理器,处理所有HTTP错误状态码,返回响应而不抛出异常"""
|
|
195
|
+
|
|
196
|
+
def http_error_default(self, req, fp, code, msg, hdrs):
|
|
197
|
+
"""处理所有HTTP错误状态码,返回响应而不抛出异常"""
|
|
198
|
+
logger.info("HTTP error %s: %s", code, msg)
|
|
199
|
+
return fp
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class AutoSSLHandler(HTTPSHandler): # type: ignore[misc]
|
|
203
|
+
"""SSL自动降级处理器,处理 unable to get local issuer certificate 错误"""
|
|
204
|
+
|
|
205
|
+
_ssl_cache = {} # type: dict[str, ssl.SSLContext|None]
|
|
206
|
+
|
|
207
|
+
def __init__(self, verify):
|
|
208
|
+
# type: (bool | str) -> None
|
|
209
|
+
self._verify = verify
|
|
210
|
+
self._context = self._ssl_context()
|
|
211
|
+
# 兼容性:优先使用context参数,失败时降级
|
|
212
|
+
try: # python 3 / python 2.7.9+
|
|
213
|
+
HTTPSHandler.__init__(self, context=self._context)
|
|
214
|
+
except (TypeError, AttributeError): # python 2.7.8-
|
|
215
|
+
HTTPSHandler.__init__(self)
|
|
216
|
+
|
|
217
|
+
def https_open(self, req):
|
|
218
|
+
"""处理HTTPS请求,自动处理SSL错误"""
|
|
219
|
+
try:
|
|
220
|
+
return self._open(req)
|
|
221
|
+
except OSError as e: # SSL auto模式:处理本地证书错误
|
|
222
|
+
ssl_errors = ("unable to get local issuer certificate", "Basic Constraints of CA cert not marked critical")
|
|
223
|
+
if self._verify == "auto" and any(err in str(e) for err in ssl_errors):
|
|
224
|
+
logger.warning("SSL error (%s), switching to unverified connection", str(e))
|
|
225
|
+
self._verify = False # 不验证SSL
|
|
226
|
+
self._context = self._ssl_context() # 确保上下文已更新
|
|
227
|
+
return self._open(req) # 重试请求
|
|
228
|
+
else:
|
|
229
|
+
logger.debug("error: (%s)", e)
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
def _open(self, req):
|
|
233
|
+
try: # python 3
|
|
234
|
+
return self.do_open(HTTPSConnection, req, context=self._context)
|
|
235
|
+
except (TypeError, AttributeError): # python 2.7.6- Fallback for older Python versions
|
|
236
|
+
logger.info("Falling back to parent https_open method for compatibility")
|
|
237
|
+
return HTTPSHandler.https_open(self, req)
|
|
238
|
+
|
|
239
|
+
def _ssl_context(self):
|
|
240
|
+
# type: () -> ssl.SSLContext | None
|
|
241
|
+
"""创建或获取缓存的SSLContext"""
|
|
242
|
+
cache_key = "default" # 缓存键
|
|
243
|
+
if not self._verify:
|
|
244
|
+
cache_key = "unverified"
|
|
245
|
+
if cache_key not in self._ssl_cache:
|
|
246
|
+
self._ssl_cache[cache_key] = (
|
|
247
|
+
ssl._create_unverified_context() if hasattr(ssl, "_create_unverified_context") else None
|
|
248
|
+
)
|
|
249
|
+
elif hasattr(self._verify, "lower") and self._verify.lower() not in ("auto", "true"): # type: ignore
|
|
250
|
+
cache_key = str(self._verify)
|
|
251
|
+
if cache_key not in self._ssl_cache:
|
|
252
|
+
self._ssl_cache[cache_key] = ssl.create_default_context(cafile=cache_key)
|
|
253
|
+
elif cache_key not in self._ssl_cache:
|
|
254
|
+
self._ssl_cache[cache_key] = ssl.create_default_context()
|
|
255
|
+
if not self._ssl_cache[cache_key].get_ca_certs(): # type: ignore
|
|
256
|
+
logger.info("No system CA certificates found, loading default CA certificates")
|
|
257
|
+
self._load_system_ca_certs(self._ssl_cache[cache_key]) # type: ignore
|
|
258
|
+
|
|
259
|
+
return self._ssl_cache[cache_key]
|
|
260
|
+
|
|
261
|
+
def _load_system_ca_certs(self, ssl_context):
|
|
262
|
+
# type: (ssl.SSLContext) -> None
|
|
263
|
+
"""加载系统CA证书"""
|
|
264
|
+
ca_paths = [
|
|
265
|
+
# Linux/Unix常用路径
|
|
266
|
+
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
|
267
|
+
"/etc/pki/tls/certs/ca-bundle.crt", # RedHat/CentOS
|
|
268
|
+
"/etc/ssl/ca-bundle.pem", # OpenSUSE
|
|
269
|
+
"/etc/ssl/cert.pem", # OpenBSD
|
|
270
|
+
"/usr/local/share/certs/ca-root-nss.crt", # FreeBSD
|
|
271
|
+
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", # Fedora/RHEL
|
|
272
|
+
# macOS路径
|
|
273
|
+
"/usr/local/etc/openssl/cert.pem", # macOS with Homebrew
|
|
274
|
+
"/opt/local/etc/openssl/cert.pem", # macOS with MacPorts
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
for ca_path in ca_paths:
|
|
278
|
+
if os.path.isfile(ca_path):
|
|
279
|
+
try:
|
|
280
|
+
ssl_context.load_verify_locations(ca_path)
|
|
281
|
+
logger.info("Loaded CA certificates from: %s", ca_path)
|
|
282
|
+
return # 成功加载后立即返回
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.warning("Failed to load CA certificates from %s: %s", ca_path, e)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class RetryHandler(BaseHandler): # type: ignore[misc]
|
|
288
|
+
"""HTTP重试处理器,自动重试指定状态码和网络错误"""
|
|
289
|
+
|
|
290
|
+
handler_order = 100
|
|
291
|
+
RETRY_CODES = (408, 429, 500, 502, 503, 504)
|
|
292
|
+
|
|
293
|
+
def __init__(self, retries=3):
|
|
294
|
+
# type: (int) -> None
|
|
295
|
+
"""初始化重试处理器"""
|
|
296
|
+
self._in_retry = False # 防止递归调用的标志
|
|
297
|
+
self.retries = retries # 始终设置retries属性
|
|
298
|
+
if retries > 0:
|
|
299
|
+
self.default_open = self._open
|
|
300
|
+
|
|
301
|
+
def _open(self, req):
|
|
302
|
+
"""实际的重试逻辑,处理所有协议"""
|
|
303
|
+
if self._in_retry:
|
|
304
|
+
return None # 防止递归调用
|
|
305
|
+
|
|
306
|
+
self._in_retry = True
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
for attempt in range(1, self.retries + 1):
|
|
310
|
+
try:
|
|
311
|
+
res = self.parent.open(req, timeout=req.timeout)
|
|
312
|
+
if not hasattr(res, "getcode") or res.getcode() not in self.RETRY_CODES:
|
|
313
|
+
return res # 成功响应直接返回
|
|
314
|
+
logger.warning("HTTP %d error, retrying in %d seconds", res.getcode(), 2**attempt)
|
|
315
|
+
except (socket.timeout, socket.gaierror, socket.herror) as e:
|
|
316
|
+
logger.warning("Request failed, retrying in %d seconds: %s", 2**attempt, str(e))
|
|
317
|
+
|
|
318
|
+
time.sleep(2**attempt)
|
|
319
|
+
continue
|
|
320
|
+
return self.parent.open(req, timeout=req.timeout) # 最后一次尝试
|
|
321
|
+
finally:
|
|
322
|
+
self._in_retry = False
|