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/__builtins__.pyi +5 -0
- ddns/__init__.py +6 -4
- ddns/__main__.py +75 -69
- ddns/provider/__init__.py +59 -0
- ddns/provider/_base.py +659 -0
- ddns/provider/alidns.py +127 -176
- ddns/provider/callback.py +67 -105
- ddns/provider/cloudflare.py +87 -151
- ddns/provider/debug.py +20 -0
- ddns/provider/dnscom.py +91 -169
- ddns/provider/dnspod.py +105 -174
- ddns/provider/dnspod_com.py +11 -8
- ddns/provider/he.py +41 -78
- ddns/provider/huaweidns.py +133 -256
- ddns/provider/tencentcloud.py +195 -0
- ddns/util/cache.py +67 -54
- ddns/util/config.py +29 -32
- ddns/util/http.py +277 -0
- ddns/util/ip.py +20 -17
- {ddns-4.0.1.dist-info → ddns-4.1.0b1.dist-info}/METADATA +96 -71
- ddns-4.1.0b1.dist-info/RECORD +26 -0
- ddns-4.0.1.dist-info/RECORD +0 -21
- {ddns-4.0.1.dist-info → ddns-4.1.0b1.dist-info}/WHEEL +0 -0
- {ddns-4.0.1.dist-info → ddns-4.1.0b1.dist-info}/entry_points.txt +0 -0
- {ddns-4.0.1.dist-info → ddns-4.1.0b1.dist-info}/licenses/LICENSE +0 -0
- {ddns-4.0.1.dist-info → ddns-4.1.0b1.dist-info}/top_level.txt +0 -0
ddns/util/cache.py
CHANGED
|
@@ -4,30 +4,25 @@ cache module
|
|
|
4
4
|
文件缓存
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
|
|
8
7
|
from os import path, stat
|
|
9
8
|
from pickle import dump, load
|
|
10
9
|
from time import time
|
|
10
|
+
from logging import getLogger, Logger # noqa: F401
|
|
11
11
|
|
|
12
|
-
from logging import info, debug, warning
|
|
13
|
-
|
|
14
|
-
try: # python 3
|
|
15
|
-
from collections.abc import MutableMapping
|
|
16
|
-
except ImportError: # python 2
|
|
17
|
-
from collections import MutableMapping
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
class Cache(MutableMapping):
|
|
13
|
+
class Cache(dict):
|
|
21
14
|
"""
|
|
22
15
|
using file to Cache data as dictionary
|
|
23
16
|
"""
|
|
24
17
|
|
|
25
|
-
def __init__(self, path, sync=False):
|
|
26
|
-
|
|
18
|
+
def __init__(self, path, logger=None, sync=False):
|
|
19
|
+
# type: (str, Logger | None, bool) -> None
|
|
20
|
+
super(Cache, self).__init__()
|
|
27
21
|
self.__filename = path
|
|
28
22
|
self.__sync = sync
|
|
29
23
|
self.__time = time()
|
|
30
24
|
self.__changed = False
|
|
25
|
+
self.__logger = (logger or getLogger()).getChild("Cache")
|
|
31
26
|
self.load()
|
|
32
27
|
|
|
33
28
|
@property
|
|
@@ -35,7 +30,7 @@ class Cache(MutableMapping):
|
|
|
35
30
|
"""
|
|
36
31
|
缓存修改时间
|
|
37
32
|
"""
|
|
38
|
-
return self.__time
|
|
33
|
+
return self.__time or 0
|
|
39
34
|
|
|
40
35
|
def load(self, file=None):
|
|
41
36
|
"""
|
|
@@ -44,44 +39,35 @@ class Cache(MutableMapping):
|
|
|
44
39
|
if not file:
|
|
45
40
|
file = self.__filename
|
|
46
41
|
|
|
47
|
-
debug(
|
|
48
|
-
if path.isfile(file):
|
|
49
|
-
with open(
|
|
42
|
+
self.__logger.debug("load cache data from %s", file)
|
|
43
|
+
if file and path.isfile(file):
|
|
44
|
+
with open(file, "rb") as data:
|
|
50
45
|
try:
|
|
51
|
-
|
|
46
|
+
loaded_data = load(data)
|
|
47
|
+
self.clear()
|
|
48
|
+
self.update(loaded_data)
|
|
52
49
|
self.__time = stat(file).st_mtime
|
|
53
50
|
return self
|
|
54
51
|
except ValueError:
|
|
55
52
|
pass
|
|
56
53
|
except Exception as e:
|
|
57
|
-
warning(e)
|
|
54
|
+
self.__logger.warning(e)
|
|
58
55
|
else:
|
|
59
|
-
info(
|
|
56
|
+
self.__logger.info("cache file not exist")
|
|
60
57
|
|
|
61
|
-
self.
|
|
58
|
+
self.clear()
|
|
62
59
|
self.__time = time()
|
|
63
60
|
self.__changed = True
|
|
64
61
|
return self
|
|
65
62
|
|
|
66
|
-
def data(self, key=None, default=None):
|
|
67
|
-
"""
|
|
68
|
-
获取当前字典或者制定得键值
|
|
69
|
-
"""
|
|
70
|
-
if self.__sync:
|
|
71
|
-
self.load()
|
|
72
|
-
|
|
73
|
-
if key is None:
|
|
74
|
-
return self.__data
|
|
75
|
-
else:
|
|
76
|
-
return self.__data.get(key, default)
|
|
77
|
-
|
|
78
63
|
def sync(self):
|
|
79
|
-
"""Sync the write buffer with the cache files and clear the buffer.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
64
|
+
"""Sync the write buffer with the cache files and clear the buffer."""
|
|
65
|
+
if self.__changed and self.__filename:
|
|
66
|
+
with open(self.__filename, "wb") as data:
|
|
67
|
+
# 只保存非私有字段(不以__开头的字段)
|
|
68
|
+
filtered_data = {k: v for k, v in super(Cache, self).items() if not k.startswith("__")}
|
|
69
|
+
dump(filtered_data, data)
|
|
70
|
+
self.__logger.debug("save cache data to %s", self.__filename)
|
|
85
71
|
self.__time = time()
|
|
86
72
|
self.__changed = False
|
|
87
73
|
return self
|
|
@@ -91,10 +77,10 @@ class Cache(MutableMapping):
|
|
|
91
77
|
If a closed :class:`FileCache` object's methods are called, a
|
|
92
78
|
:exc:`ValueError` will be raised.
|
|
93
79
|
"""
|
|
94
|
-
self.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
80
|
+
if self.__filename:
|
|
81
|
+
self.sync()
|
|
82
|
+
self.__filename = None
|
|
83
|
+
self.__time = None
|
|
98
84
|
self.__sync = False
|
|
99
85
|
|
|
100
86
|
def __update(self):
|
|
@@ -105,35 +91,62 @@ class Cache(MutableMapping):
|
|
|
105
91
|
self.__time = time()
|
|
106
92
|
|
|
107
93
|
def clear(self):
|
|
108
|
-
|
|
109
|
-
|
|
94
|
+
# 只清除非私有字段(不以__开头的字段)
|
|
95
|
+
keys_to_remove = [key for key in super(Cache, self).keys() if not key.startswith("__")]
|
|
96
|
+
if keys_to_remove:
|
|
97
|
+
for key in keys_to_remove:
|
|
98
|
+
super(Cache, self).__delitem__(key)
|
|
110
99
|
self.__update()
|
|
111
100
|
|
|
101
|
+
def get(self, key, default=None):
|
|
102
|
+
"""
|
|
103
|
+
获取指定键的值,如果键不存在则返回默认值
|
|
104
|
+
:param key: 键
|
|
105
|
+
:param default: 默认值
|
|
106
|
+
:return: 键对应的值或默认值
|
|
107
|
+
"""
|
|
108
|
+
if key is None and default is None:
|
|
109
|
+
return {k: v for k, v in super(Cache, self).items() if not k.startswith("__")}
|
|
110
|
+
return super(Cache, self).get(key, default)
|
|
111
|
+
|
|
112
112
|
def __setitem__(self, key, value):
|
|
113
|
-
if self.
|
|
114
|
-
self.
|
|
115
|
-
|
|
113
|
+
if self.get(key) != value:
|
|
114
|
+
super(Cache, self).__setitem__(key, value)
|
|
115
|
+
# 私有字段(以__开头)不触发同步
|
|
116
|
+
if not key.startswith("__"):
|
|
117
|
+
self.__update()
|
|
116
118
|
|
|
117
119
|
def __delitem__(self, key):
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
# 检查键是否存在,如果不存在则直接返回,不抛错
|
|
121
|
+
if not super(Cache, self).__contains__(key):
|
|
122
|
+
return
|
|
123
|
+
super(Cache, self).__delitem__(key)
|
|
124
|
+
# 私有字段(以__开头)不触发同步
|
|
125
|
+
if not key.startswith("__"):
|
|
120
126
|
self.__update()
|
|
121
127
|
|
|
122
128
|
def __getitem__(self, key):
|
|
123
|
-
return self.
|
|
129
|
+
return super(Cache, self).__getitem__(key)
|
|
124
130
|
|
|
125
131
|
def __iter__(self):
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
# 只迭代非私有字段(不以__开头的字段)
|
|
133
|
+
for key in super(Cache, self).__iter__():
|
|
134
|
+
if not key.startswith("__"):
|
|
135
|
+
yield key
|
|
136
|
+
|
|
137
|
+
def __items__(self):
|
|
138
|
+
# 只返回非私有字段(不以__开头的字段)
|
|
139
|
+
return ((key, value) for key, value in super(Cache, self).items() if not key.startswith("__"))
|
|
128
140
|
|
|
129
141
|
def __len__(self):
|
|
130
|
-
|
|
142
|
+
# 不计算以__开头的私有字段
|
|
143
|
+
return len([key for key in super(Cache, self).keys() if not key.startswith("__")])
|
|
131
144
|
|
|
132
145
|
def __contains__(self, key):
|
|
133
|
-
return
|
|
146
|
+
return super(Cache, self).__contains__(key)
|
|
134
147
|
|
|
135
148
|
def __str__(self):
|
|
136
|
-
return self
|
|
149
|
+
return super(Cache, self).__str__()
|
|
137
150
|
|
|
138
151
|
def __del__(self):
|
|
139
152
|
self.close()
|
ddns/util/config.py
CHANGED
|
@@ -54,7 +54,7 @@ def parse_array_string(value, enable_simple_split):
|
|
|
54
54
|
仅当 trim 之后以 '[' 开头以 ']' 结尾时,才尝试使用 ast.literal_eval 解析
|
|
55
55
|
默认返回原始字符串
|
|
56
56
|
"""
|
|
57
|
-
if not
|
|
57
|
+
if not hasattr(value, "strip"): # 非字符串
|
|
58
58
|
return value
|
|
59
59
|
|
|
60
60
|
trimmed = value.strip()
|
|
@@ -71,10 +71,10 @@ def parse_array_string(value, enable_simple_split):
|
|
|
71
71
|
elif enable_simple_split:
|
|
72
72
|
# 尝试使用逗号或分号分隔符解析
|
|
73
73
|
sep = None
|
|
74
|
-
if
|
|
75
|
-
sep =
|
|
76
|
-
elif
|
|
77
|
-
sep =
|
|
74
|
+
if "," in trimmed:
|
|
75
|
+
sep = ","
|
|
76
|
+
elif ";" in trimmed:
|
|
77
|
+
sep = ";"
|
|
78
78
|
if sep:
|
|
79
79
|
return [item.strip() for item in trimmed.split(sep) if item.strip()]
|
|
80
80
|
return value
|
|
@@ -84,9 +84,8 @@ def get_system_info_str():
|
|
|
84
84
|
system = platform.system()
|
|
85
85
|
release = platform.release()
|
|
86
86
|
machine = platform.machine()
|
|
87
|
-
arch = platform.architecture()
|
|
88
|
-
|
|
89
|
-
return "{}-{} {} ({})".format(system, release, machine, arch)
|
|
87
|
+
arch = platform.architecture()
|
|
88
|
+
return "{}-{} {} {}".format(system, release, machine, arch)
|
|
90
89
|
|
|
91
90
|
|
|
92
91
|
def get_python_info_str():
|
|
@@ -100,21 +99,13 @@ def init_config(description, doc, version, date):
|
|
|
100
99
|
配置
|
|
101
100
|
"""
|
|
102
101
|
global __cli_args
|
|
103
|
-
parser = ArgumentParser(
|
|
104
|
-
description=description, epilog=doc, formatter_class=RawTextHelpFormatter
|
|
105
|
-
)
|
|
102
|
+
parser = ArgumentParser(description=description, epilog=doc, formatter_class=RawTextHelpFormatter)
|
|
106
103
|
sysinfo = get_system_info_str()
|
|
107
104
|
pyinfo = get_python_info_str()
|
|
108
105
|
version_str = "v{} ({})\n{}\n{}".format(version, date, pyinfo, sysinfo)
|
|
109
106
|
parser.add_argument("-v", "--version", action="version", version=version_str)
|
|
110
|
-
parser.add_argument(
|
|
111
|
-
|
|
112
|
-
)
|
|
113
|
-
parser.add_argument(
|
|
114
|
-
"--debug",
|
|
115
|
-
action="store_true",
|
|
116
|
-
help="debug mode [调试模式等效 --log.level=DEBUG]",
|
|
117
|
-
)
|
|
107
|
+
parser.add_argument("-c", "--config", metavar="FILE", help="load config file [配置文件路径]")
|
|
108
|
+
parser.add_argument("--debug", action="store_true", help="debug mode [开启调试模式]")
|
|
118
109
|
|
|
119
110
|
# 参数定义
|
|
120
111
|
parser.add_argument(
|
|
@@ -129,6 +120,7 @@ def init_config(description, doc, version, date):
|
|
|
129
120
|
"he",
|
|
130
121
|
"huaweidns",
|
|
131
122
|
"callback",
|
|
123
|
+
"debug",
|
|
132
124
|
],
|
|
133
125
|
)
|
|
134
126
|
parser.add_argument("--id", help="API ID or email [对应账号ID或邮箱]")
|
|
@@ -162,6 +154,7 @@ def init_config(description, doc, version, date):
|
|
|
162
154
|
help="IPv6 domains [IPv6域名列表, 可配置多个域名]",
|
|
163
155
|
)
|
|
164
156
|
parser.add_argument("--ttl", type=int, help="DNS TTL(s) [设置域名解析过期时间]")
|
|
157
|
+
parser.add_argument("--line", help="DNS line/route [DNS线路设置,如电信、联通、移动等]")
|
|
165
158
|
parser.add_argument(
|
|
166
159
|
"--proxy",
|
|
167
160
|
nargs="*",
|
|
@@ -183,23 +176,25 @@ def init_config(description, doc, version, date):
|
|
|
183
176
|
help="disable cache [关闭缓存等效 --cache=false]",
|
|
184
177
|
)
|
|
185
178
|
parser.add_argument(
|
|
186
|
-
"--
|
|
179
|
+
"--ssl",
|
|
180
|
+
help="SSL certificate verification [SSL证书验证方式]: "
|
|
181
|
+
"true(强制验证), false(禁用验证), auto(自动降级), /path/to/cert.pem(自定义证书)",
|
|
187
182
|
)
|
|
183
|
+
parser.add_argument("--log.file", metavar="FILE", help="log file [日志文件,默认标准输出]")
|
|
188
184
|
parser.add_argument("--log.level", type=log_level, metavar="|".join(log_levels))
|
|
189
|
-
parser.add_argument(
|
|
190
|
-
|
|
191
|
-
)
|
|
192
|
-
parser.add_argument(
|
|
193
|
-
"--log.datefmt", metavar="FORMAT", help="date format [日志时间打印格式]"
|
|
194
|
-
)
|
|
185
|
+
parser.add_argument("--log.format", metavar="FORMAT", help="log format [设置日志打印格式]")
|
|
186
|
+
parser.add_argument("--log.datefmt", metavar="FORMAT", help="date format [日志时间打印格式]")
|
|
195
187
|
|
|
196
188
|
__cli_args = parser.parse_args()
|
|
197
|
-
|
|
198
|
-
|
|
189
|
+
is_debug = getattr(__cli_args, "debug", False)
|
|
190
|
+
if is_debug:
|
|
191
|
+
# 如果启用调试模式,则强制设置日志级别为 DEBUG
|
|
199
192
|
setattr(__cli_args, "log.level", log_level("DEBUG"))
|
|
193
|
+
if not hasattr(__cli_args, "cache"):
|
|
194
|
+
setattr(__cli_args, "cache", False) # 禁用缓存
|
|
200
195
|
|
|
201
|
-
|
|
202
|
-
config_file = get_config("config")
|
|
196
|
+
config_required = not get_config("token") and not get_config("id")
|
|
197
|
+
config_file = get_config("config") # type: str | None # type: ignore
|
|
203
198
|
if not config_file:
|
|
204
199
|
# 未指定配置文件且需要读取文件时,依次查找
|
|
205
200
|
cfgs = [
|
|
@@ -212,7 +207,7 @@ def init_config(description, doc, version, date):
|
|
|
212
207
|
if path.isfile(config_file):
|
|
213
208
|
__load_config(config_file)
|
|
214
209
|
__cli_args.config = config_file
|
|
215
|
-
elif
|
|
210
|
+
elif config_required:
|
|
216
211
|
error("Config file is required, but not found: %s", config_file)
|
|
217
212
|
# 如果需要配置文件但没有指定,则自动生成
|
|
218
213
|
if generate_config(config_file):
|
|
@@ -296,13 +291,15 @@ def generate_config(config_path):
|
|
|
296
291
|
"$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
|
|
297
292
|
"id": "YOUR ID or EMAIL for DNS Provider",
|
|
298
293
|
"token": "YOUR TOKEN or KEY for DNS Provider",
|
|
299
|
-
"dns": "
|
|
294
|
+
"dns": "debug", # DNS Provider, default is print
|
|
300
295
|
"ipv4": ["newfuture.cc", "ddns.newfuture.cc"],
|
|
301
296
|
"ipv6": ["newfuture.cc", "ipv6.ddns.newfuture.cc"],
|
|
302
297
|
"index4": "default",
|
|
303
298
|
"index6": "default",
|
|
304
299
|
"ttl": None,
|
|
300
|
+
"line": None,
|
|
305
301
|
"proxy": None,
|
|
302
|
+
"ssl": "auto",
|
|
306
303
|
"log": {"level": "INFO"},
|
|
307
304
|
}
|
|
308
305
|
try:
|
ddns/util/http.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# coding=utf-8
|
|
2
|
+
"""
|
|
3
|
+
HTTP请求工具模块
|
|
4
|
+
|
|
5
|
+
HTTP utilities module for DDNS project.
|
|
6
|
+
Provides common HTTP functionality including redirect following support.
|
|
7
|
+
|
|
8
|
+
@author: NewFuture
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from logging import getLogger
|
|
12
|
+
import ssl
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
try: # python 3
|
|
16
|
+
from http.client import HTTPSConnection, HTTPConnection, HTTPException
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
except ImportError: # python 2
|
|
19
|
+
from httplib import HTTPSConnection, HTTPConnection, HTTPException # type: ignore[no-redef]
|
|
20
|
+
from urlparse import urlparse # type: ignore[no-redef]
|
|
21
|
+
|
|
22
|
+
__all__ = ["send_http_request", "HttpResponse"]
|
|
23
|
+
|
|
24
|
+
logger = getLogger().getChild(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpResponse(object):
|
|
28
|
+
"""HTTP响应封装类"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, status, reason, headers, body):
|
|
31
|
+
# type: (int, str, list[tuple[str, str]], str) -> None
|
|
32
|
+
"""
|
|
33
|
+
初始化HTTP响应对象
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
status (int): HTTP状态码
|
|
37
|
+
reason (str): 状态原因短语
|
|
38
|
+
headers (list[tuple[str, str]]): 响应头列表,保持原始格式和顺序
|
|
39
|
+
body (str): 响应体内容
|
|
40
|
+
"""
|
|
41
|
+
self.status = status
|
|
42
|
+
self.reason = reason
|
|
43
|
+
self.headers = headers
|
|
44
|
+
self.body = body
|
|
45
|
+
|
|
46
|
+
def get_header(self, name, default=None):
|
|
47
|
+
# type: (str, str | None) -> str | None
|
|
48
|
+
"""
|
|
49
|
+
获取指定名称的头部值(不区分大小写)
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
name (str): 头部名称
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
str | None: 头部值,如果不存在则返回None
|
|
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
|
|
143
|
+
"""
|
|
144
|
+
发送HTTP/HTTPS请求,支持重定向跟随和灵活的SSL验证
|
|
145
|
+
Send HTTP/HTTPS request with support for redirect following and flexible SSL verification.
|
|
146
|
+
Args:
|
|
147
|
+
method (str): HTTP方法,如GET、POST等
|
|
148
|
+
url (str): 请求的URL
|
|
149
|
+
body (str | bytes | None): 请求体
|
|
150
|
+
headers (dict[str, str] | None): 请求头
|
|
151
|
+
proxy (str | None): 代理地址
|
|
152
|
+
max_redirects (int): 最大重定向次数
|
|
153
|
+
verify_ssl (bool | str): 是否验证SSL证书
|
|
154
|
+
Returns:
|
|
155
|
+
HttpResponse: 响应对象,包含状态码、头部和解码后的内容
|
|
156
|
+
Raises:
|
|
157
|
+
HTTPException: 如果请求失败或重定向次数超过限制
|
|
158
|
+
ssl.SSLError: 如果SSL验证失败
|
|
159
|
+
"""
|
|
160
|
+
if max_redirects <= 0:
|
|
161
|
+
raise HTTPException("Too many redirects")
|
|
162
|
+
|
|
163
|
+
# 解析URL
|
|
164
|
+
url_obj = urlparse(url)
|
|
165
|
+
is_https = url_obj.scheme == "https"
|
|
166
|
+
hostname = url_obj.hostname or url_obj.netloc.split(":")[0]
|
|
167
|
+
request_path = "{}?{}".format(url_obj.path, url_obj.query) if url_obj.query else url_obj.path
|
|
168
|
+
headers = headers or {}
|
|
169
|
+
|
|
170
|
+
# 创建连接
|
|
171
|
+
actual_verify_ssl = verify_ssl
|
|
172
|
+
conn = _create_connection(hostname, url_obj.port, is_https, proxy, verify_ssl)
|
|
173
|
+
|
|
174
|
+
# 执行请求,处理SSL错误
|
|
175
|
+
try:
|
|
176
|
+
conn.request(method, request_path, body, headers)
|
|
177
|
+
response = conn.getresponse()
|
|
178
|
+
except ssl.SSLError:
|
|
179
|
+
_close_connection(conn)
|
|
180
|
+
if verify_ssl == "auto" and is_https:
|
|
181
|
+
logger.warning("SSL verification failed, switching to unverified connection %s", url)
|
|
182
|
+
# 重新连接,忽略SSL验证
|
|
183
|
+
conn = _create_connection(hostname, url_obj.port, is_https, proxy, False)
|
|
184
|
+
conn.request(method, request_path, body, headers)
|
|
185
|
+
response = conn.getresponse()
|
|
186
|
+
actual_verify_ssl = False
|
|
187
|
+
else:
|
|
188
|
+
raise
|
|
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
|
+
|
|
228
|
+
|
|
229
|
+
def _build_redirect_url(location, base, path):
|
|
230
|
+
# type: (str, str, str) -> str
|
|
231
|
+
"""构建重定向URL,使用简单的字符串操作"""
|
|
232
|
+
if location.startswith("http"):
|
|
233
|
+
return location
|
|
234
|
+
|
|
235
|
+
if location.startswith("/"):
|
|
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
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _decode_response_body(raw_body, content_type):
|
|
247
|
+
# type: (bytes, str | None) -> str
|
|
248
|
+
"""解码HTTP响应体,优先使用UTF-8"""
|
|
249
|
+
if not raw_body:
|
|
250
|
+
return ""
|
|
251
|
+
|
|
252
|
+
# 从Content-Type提取charset
|
|
253
|
+
charsets = ["utf-8", "gbk", "ascii", "latin-1"]
|
|
254
|
+
if content_type and "charset=" in content_type.lower():
|
|
255
|
+
start = content_type.lower().find("charset=") + 8
|
|
256
|
+
end = content_type.find(";", start)
|
|
257
|
+
if end == -1:
|
|
258
|
+
end = len(content_type)
|
|
259
|
+
charset = content_type[start:end].strip("'\" ").lower()
|
|
260
|
+
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
|
+
|
|
269
|
+
# 按优先级尝试解码
|
|
270
|
+
for encoding in charsets:
|
|
271
|
+
try:
|
|
272
|
+
return raw_body.decode(encoding)
|
|
273
|
+
except (UnicodeDecodeError, LookupError):
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
# 最终后备:UTF-8替换错误字符
|
|
277
|
+
return raw_body.decode("utf-8", errors="replace")
|