ddns 4.1.0b4__py2.py3-none-any.whl → 4.1.1__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.
- ddns/__init__.py +2 -2
- ddns/__main__.py +7 -2
- ddns/config/__init__.py +171 -0
- ddns/config/cli.py +366 -0
- ddns/config/config.py +214 -0
- ddns/config/env.py +77 -0
- ddns/config/file.py +169 -0
- ddns/provider/cloudflare.py +25 -6
- ddns/scheduler/__init__.py +72 -0
- ddns/scheduler/_base.py +80 -0
- ddns/scheduler/cron.py +111 -0
- ddns/scheduler/launchd.py +130 -0
- ddns/scheduler/schtasks.py +120 -0
- ddns/scheduler/systemd.py +139 -0
- {ddns-4.1.0b4.dist-info → ddns-4.1.1.dist-info}/METADATA +1 -1
- {ddns-4.1.0b4.dist-info → ddns-4.1.1.dist-info}/RECORD +20 -9
- {ddns-4.1.0b4.dist-info → ddns-4.1.1.dist-info}/WHEEL +0 -0
- {ddns-4.1.0b4.dist-info → ddns-4.1.1.dist-info}/entry_points.txt +0 -0
- {ddns-4.1.0b4.dist-info → ddns-4.1.1.dist-info}/licenses/LICENSE +0 -0
- {ddns-4.1.0b4.dist-info → ddns-4.1.1.dist-info}/top_level.txt +0 -0
ddns/config/config.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# coding=utf-8
|
|
2
|
+
"""
|
|
3
|
+
Configuration class merged from CLI, JSON, and environment variables.
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from hashlib import md5
|
|
8
|
+
from .cli import str_bool, log_level as get_log_level
|
|
9
|
+
|
|
10
|
+
__all__ = ["Config", "split_array_string"]
|
|
11
|
+
|
|
12
|
+
# 简单数组,支持',', ';' 分隔的参数列表
|
|
13
|
+
SIMPLE_ARRAY_PARAMS = ["ipv4", "ipv6", "proxy", "index4", "index6"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_false(value):
|
|
17
|
+
"""
|
|
18
|
+
判断值是否为 False
|
|
19
|
+
字符串 'false', 或者 False, 或者 'none';
|
|
20
|
+
0 不是 False
|
|
21
|
+
"""
|
|
22
|
+
if hasattr(value, "strip"): # 字符串
|
|
23
|
+
return value.strip().lower() in ["false", "none"]
|
|
24
|
+
return value is False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def split_array_string(value):
|
|
28
|
+
# type: (str|list) -> list
|
|
29
|
+
"""
|
|
30
|
+
解析数组字符串
|
|
31
|
+
逐个分解,遇到特殊前缀时停止分割
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(value, list):
|
|
34
|
+
return value
|
|
35
|
+
if not value or not hasattr(value, "strip"):
|
|
36
|
+
return [value] if value else []
|
|
37
|
+
|
|
38
|
+
trimmed = value.strip()
|
|
39
|
+
|
|
40
|
+
# 选择分隔符(逗号优先)
|
|
41
|
+
sep = "," if "," in trimmed else (";" if ";" in trimmed else None)
|
|
42
|
+
if not sep:
|
|
43
|
+
return [trimmed]
|
|
44
|
+
|
|
45
|
+
# 逐个分解,遇到特殊前缀时停止
|
|
46
|
+
parts = []
|
|
47
|
+
split_parts = trimmed.split(sep)
|
|
48
|
+
for i, part in enumerate(split_parts):
|
|
49
|
+
part = part.strip()
|
|
50
|
+
if not part:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
# 检查是否包含特殊前缀,如果有则合并剩余部分
|
|
54
|
+
if any(prefix in part for prefix in ["regex:", "cmd:", "shell:"]):
|
|
55
|
+
parts.append(sep.join(split_parts[i:]).strip())
|
|
56
|
+
break
|
|
57
|
+
parts.append(part)
|
|
58
|
+
|
|
59
|
+
return parts
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Config(object):
|
|
63
|
+
"""
|
|
64
|
+
Configuration class for DDNS.
|
|
65
|
+
This class is used to load and manage configuration settings.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, cli_config=None, json_config=None, env_config=None):
|
|
69
|
+
# type: (dict | None, dict | None, dict | None) -> None
|
|
70
|
+
"""
|
|
71
|
+
Initialize the Config object.
|
|
72
|
+
"""
|
|
73
|
+
self._cli_config = cli_config or {}
|
|
74
|
+
self._json_config = json_config or {}
|
|
75
|
+
self._env_config = env_config or {}
|
|
76
|
+
|
|
77
|
+
# Known configuration keys that should not go into extra
|
|
78
|
+
self._known_keys = {
|
|
79
|
+
"dns",
|
|
80
|
+
"id",
|
|
81
|
+
"token",
|
|
82
|
+
"endpoint",
|
|
83
|
+
"index4",
|
|
84
|
+
"index6",
|
|
85
|
+
"ipv4",
|
|
86
|
+
"ipv6",
|
|
87
|
+
"ttl",
|
|
88
|
+
"line",
|
|
89
|
+
"proxy",
|
|
90
|
+
"cache",
|
|
91
|
+
"ssl",
|
|
92
|
+
"log_level",
|
|
93
|
+
"log_format",
|
|
94
|
+
"log_file",
|
|
95
|
+
"log_datefmt",
|
|
96
|
+
"extra",
|
|
97
|
+
"debug",
|
|
98
|
+
"config",
|
|
99
|
+
"command",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# dns related configurations
|
|
103
|
+
self.dns = self._get("dns", "") # type: str
|
|
104
|
+
self.id = self._get("id", "") # type: str
|
|
105
|
+
self.token = self._get("token", "") # type: str
|
|
106
|
+
self.endpoint = self._get("endpoint") # type: str | None
|
|
107
|
+
self.index4 = self._get("index4", ["default"]) # type: list[str]|Literal[False]
|
|
108
|
+
self.index6 = self._get("index6", ["default"]) # type: list[str]|Literal[False]
|
|
109
|
+
self.ipv4 = self._get("ipv4", []) # type: list[str]
|
|
110
|
+
self.ipv6 = self._get("ipv6", []) # type: list[str]
|
|
111
|
+
ttl = self._get("ttl", None) # type: int | str | None
|
|
112
|
+
self.ttl = int(ttl) if isinstance(ttl, (str, bytes)) else ttl # type: int | None
|
|
113
|
+
self.line = self._get("line", None) # type: str | None
|
|
114
|
+
self.proxy = self._get("proxy", []) # type: list[str] | None
|
|
115
|
+
# cache and SSL settings
|
|
116
|
+
self.cache = str_bool(self._get("cache", True))
|
|
117
|
+
self.ssl = str_bool(self._get("ssl", "auto"))
|
|
118
|
+
|
|
119
|
+
log_level = self._get("log_level", "INFO")
|
|
120
|
+
if isinstance(log_level, (str, bytes)):
|
|
121
|
+
log_level = get_log_level(log_level)
|
|
122
|
+
self.log_level = log_level # type: int # type: ignore[assignment]
|
|
123
|
+
self.log_format = self._get("log_format", None) # type: str | None
|
|
124
|
+
self.log_file = self._get("log_file", None) # type: str | None
|
|
125
|
+
self.log_datefmt = self._get("log_datefmt", "%Y-%m-%dT%H:%M:%S") # type: str | None
|
|
126
|
+
|
|
127
|
+
# Collect extra fields from all config sources
|
|
128
|
+
self.extra = self._collect_extra() # type: dict
|
|
129
|
+
|
|
130
|
+
def _get(self, key, default=None):
|
|
131
|
+
# type: (str, Any) -> Any
|
|
132
|
+
"""
|
|
133
|
+
Get a configuration value by key.
|
|
134
|
+
"""
|
|
135
|
+
value = self._cli_config.get(key)
|
|
136
|
+
if isinstance(value, list) and len(value) == 1:
|
|
137
|
+
# 如果是单个元素的列表,取出第一个元素, 这样可以避免在 CLI 配置中使用单个值时仍然得到一个列表
|
|
138
|
+
value = value[0]
|
|
139
|
+
if value is None:
|
|
140
|
+
value = self._json_config.get(key, self._env_config.get(key, default))
|
|
141
|
+
if is_false(value):
|
|
142
|
+
return False
|
|
143
|
+
# 处理数组参数
|
|
144
|
+
if key in SIMPLE_ARRAY_PARAMS:
|
|
145
|
+
return split_array_string(value)
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
def _collect_extra(self):
|
|
149
|
+
# type: () -> dict
|
|
150
|
+
"""
|
|
151
|
+
Collect all extra fields from CLI, JSON, and ENV configs that are not known keys.
|
|
152
|
+
Priority: CLI > JSON > ENV
|
|
153
|
+
"""
|
|
154
|
+
extra = {} # type: dict
|
|
155
|
+
|
|
156
|
+
# Collect from env config first (lowest priority)
|
|
157
|
+
for key, value in self._env_config.items():
|
|
158
|
+
if key.startswith("extra_"):
|
|
159
|
+
extra_key = key[6:] # Remove "extra_" prefix
|
|
160
|
+
extra[extra_key] = value
|
|
161
|
+
elif key == "extra" and isinstance(value, dict):
|
|
162
|
+
extra.update(value)
|
|
163
|
+
elif key not in self._known_keys:
|
|
164
|
+
extra[key] = value
|
|
165
|
+
|
|
166
|
+
# Collect from JSON config (medium priority)
|
|
167
|
+
for key, value in self._json_config.items():
|
|
168
|
+
if key == "extra" and isinstance(value, dict):
|
|
169
|
+
extra.update(value)
|
|
170
|
+
elif key not in self._known_keys:
|
|
171
|
+
extra[key] = value
|
|
172
|
+
|
|
173
|
+
# Collect from CLI config (highest priority)
|
|
174
|
+
for key, value in self._cli_config.items():
|
|
175
|
+
if key.startswith("extra_"):
|
|
176
|
+
extra_key = key[6:] # Remove "extra_" prefix
|
|
177
|
+
extra[extra_key] = value
|
|
178
|
+
elif key not in self._known_keys:
|
|
179
|
+
extra[key] = value
|
|
180
|
+
|
|
181
|
+
return extra
|
|
182
|
+
|
|
183
|
+
def md5(self):
|
|
184
|
+
# type: () -> str
|
|
185
|
+
"""
|
|
186
|
+
Generate hash based on all merged configurations.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
str: Hash value based on configuration attributes.
|
|
190
|
+
"""
|
|
191
|
+
dict_var = {
|
|
192
|
+
"dns": self.dns,
|
|
193
|
+
"id": self.id,
|
|
194
|
+
"token": self.token,
|
|
195
|
+
"endpoint": self.endpoint,
|
|
196
|
+
"index4": self.index4,
|
|
197
|
+
"index6": self.index6,
|
|
198
|
+
"ipv4": self.ipv4,
|
|
199
|
+
"ipv6": self.ipv6,
|
|
200
|
+
"line": self.line,
|
|
201
|
+
"ttl": self.ttl,
|
|
202
|
+
# System settings
|
|
203
|
+
"cache": self.cache,
|
|
204
|
+
"proxy": self.proxy,
|
|
205
|
+
"ssl": self.ssl,
|
|
206
|
+
# Logging settings
|
|
207
|
+
"log_level": self.log_level,
|
|
208
|
+
"log_format": self.log_format,
|
|
209
|
+
"log_file": self.log_file,
|
|
210
|
+
"log_datefmt": self.log_datefmt,
|
|
211
|
+
# Extra fields
|
|
212
|
+
"extra": self.extra,
|
|
213
|
+
}
|
|
214
|
+
return md5(str(dict_var).encode("utf-8")).hexdigest()
|
ddns/config/env.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Configuration loader for DDNS environment variables.
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ast import literal_eval
|
|
8
|
+
from os import environ
|
|
9
|
+
from sys import stderr
|
|
10
|
+
|
|
11
|
+
__all__ = ["load_config"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _try_parse_array(value, key=None):
|
|
15
|
+
# type: (str, str|None) -> Any
|
|
16
|
+
"""解析数组值[], 非数组返回元素字符串(去除前后空格)"""
|
|
17
|
+
if not value:
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
value = value.strip()
|
|
21
|
+
# 尝试解析 JSON
|
|
22
|
+
# if (value.startswith("{'") and value.endswith("'}")) or (value.startswith('{"') and value.endswith('"}')):
|
|
23
|
+
# try:
|
|
24
|
+
# return json_decode(value)
|
|
25
|
+
# except Exception:
|
|
26
|
+
# logging.warning("Failed to parse JSON from value: %s", value)
|
|
27
|
+
if value.startswith("[") and value.endswith("]"):
|
|
28
|
+
# or (value.startswith("{") and value.endswith("}"))
|
|
29
|
+
try:
|
|
30
|
+
return literal_eval(value)
|
|
31
|
+
except Exception:
|
|
32
|
+
stderr.write("Failed to parse JSON array from value: {}={}\n".format(key, value))
|
|
33
|
+
pass
|
|
34
|
+
# 返回去除前后空格的字符串
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config(prefix="DDNS_"):
|
|
39
|
+
# type: (str) -> dict
|
|
40
|
+
"""
|
|
41
|
+
从环境变量加载配置并返回配置字典。
|
|
42
|
+
|
|
43
|
+
支持以下转换:
|
|
44
|
+
1. 对于特定的数组参数(index4, index6, ipv4, ipv6, proxy),转换为数组
|
|
45
|
+
2. 对于 JSON 格式的数组 [item1,item2],转换为数组
|
|
46
|
+
3. 键名转换:点号转下划线,支持大小写变体
|
|
47
|
+
4. 自动检测标准 Python 环境变量:
|
|
48
|
+
- SSL 验证:PYTHONHTTPSVERIFY
|
|
49
|
+
5. 支持 extra 字段:DDNS_EXTRA_XXX 会被转换为 extra_xxx
|
|
50
|
+
6. 其他所有值保持原始字符串格式,去除前后空格
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
prefix (str): 环境变量前缀,默认为 "DDNS_"
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
dict: 从环境变量解析的配置字典
|
|
57
|
+
"""
|
|
58
|
+
env_vars = {} # type: dict[str, str | list]
|
|
59
|
+
|
|
60
|
+
# 标准环境变量映射
|
|
61
|
+
alias_mappings = {"pythonhttpsverify": "ssl"}
|
|
62
|
+
|
|
63
|
+
for key, value in environ.items():
|
|
64
|
+
lower_key = key.lower()
|
|
65
|
+
config_key = None
|
|
66
|
+
|
|
67
|
+
if lower_key in alias_mappings:
|
|
68
|
+
config_key = alias_mappings[lower_key]
|
|
69
|
+
if config_key in env_vars:
|
|
70
|
+
continue # DDNS变量优先级更高
|
|
71
|
+
elif lower_key.startswith(prefix.lower()):
|
|
72
|
+
config_key = lower_key[len(prefix) :].replace(".", "_") # noqa: E203
|
|
73
|
+
|
|
74
|
+
if config_key:
|
|
75
|
+
env_vars[config_key] = _try_parse_array(value, key=key)
|
|
76
|
+
|
|
77
|
+
return env_vars
|
ddns/config/file.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Configuration file loader for DDNS. supports both JSON and AST parsing.
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ast import literal_eval
|
|
8
|
+
from json import loads as json_decode, dumps as json_encode
|
|
9
|
+
from sys import stderr, stdout
|
|
10
|
+
from ..util.comment import remove_comment
|
|
11
|
+
from ..util.http import request
|
|
12
|
+
from ..util.fileio import read_file, write_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _process_multi_providers(config):
|
|
16
|
+
# type: (dict) -> list[dict]
|
|
17
|
+
"""Process v4.1 providers format and return list of configs."""
|
|
18
|
+
result = []
|
|
19
|
+
|
|
20
|
+
# 提取全局配置(除providers之外的所有配置)
|
|
21
|
+
global_config = _flatten_single_config(config, exclude_keys=["providers"])
|
|
22
|
+
|
|
23
|
+
# 检查providers和dns字段不能同时使用
|
|
24
|
+
if global_config.get("dns"):
|
|
25
|
+
stderr.write("Error: 'providers' and 'dns' fields cannot be used simultaneously in config file!\n")
|
|
26
|
+
raise ValueError("providers and dns fields conflict")
|
|
27
|
+
|
|
28
|
+
# 为每个provider创建独立配置
|
|
29
|
+
for provider_config in config["providers"]:
|
|
30
|
+
# 验证provider必须有provider字段
|
|
31
|
+
if not provider_config.get("provider"):
|
|
32
|
+
stderr.write("Error: Each provider must have a 'provider' field!\n")
|
|
33
|
+
raise ValueError("provider missing provider field")
|
|
34
|
+
|
|
35
|
+
flat_config = global_config.copy() # 从全局配置开始
|
|
36
|
+
provider_flat = _flatten_single_config(provider_config, exclude_keys=["provider"])
|
|
37
|
+
flat_config["dns"] = provider_config.get("provider")
|
|
38
|
+
flat_config.update(provider_flat)
|
|
39
|
+
result.append(flat_config)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _flatten_single_config(config, exclude_keys=None):
|
|
44
|
+
# type: (dict, list[str]|None) -> dict
|
|
45
|
+
"""Flatten a single config object with optional key exclusion."""
|
|
46
|
+
if exclude_keys is None:
|
|
47
|
+
exclude_keys = []
|
|
48
|
+
flat_config = {}
|
|
49
|
+
for k, v in config.items():
|
|
50
|
+
if k in exclude_keys:
|
|
51
|
+
continue
|
|
52
|
+
if isinstance(v, dict):
|
|
53
|
+
for subk, subv in v.items():
|
|
54
|
+
flat_config["{}_{}".format(k, subk)] = subv
|
|
55
|
+
else:
|
|
56
|
+
flat_config[k] = v
|
|
57
|
+
return flat_config
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_config(config_path, proxy=None, ssl="auto"):
|
|
61
|
+
# type: (str, list[str] | None, bool | str) -> dict|list[dict]
|
|
62
|
+
"""
|
|
63
|
+
加载配置文件并返回配置字典或配置字典数组。
|
|
64
|
+
支持本地文件和远程HTTP(S) URL。
|
|
65
|
+
对于单个对象返回dict,对于数组返回list[dict]。
|
|
66
|
+
优先尝试JSON解析,失败后尝试AST解析。
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
config_path (str): 配置文件路径或HTTP(S) URL
|
|
70
|
+
proxy (list[str] | None): 代理列表,仅用于HTTP或HTTPS请求
|
|
71
|
+
ssl (bool | str): SSL验证配置,仅用于HTTPS请求
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
dict|list[dict]: 配置字典或配置字典数组
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
Exception: 当配置文件加载失败时抛出异常
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
# 检查是否为远程URL
|
|
81
|
+
if "://" in config_path:
|
|
82
|
+
# 使用HTTP请求获取远程配置
|
|
83
|
+
response = request("GET", config_path, proxies=proxy, verify=ssl, retries=3)
|
|
84
|
+
if (response.status not in (200, None)) or not response.body:
|
|
85
|
+
stderr.write("Failed to load {}: HTTP {} {}\n".format(config_path, response.status, response.reason))
|
|
86
|
+
stderr.write("Response body: %s\n" % response.body)
|
|
87
|
+
raise Exception("HTTP {}: {}".format(response.status, response.reason))
|
|
88
|
+
content = response.body
|
|
89
|
+
else:
|
|
90
|
+
# 本地文件加载
|
|
91
|
+
content = read_file(config_path)
|
|
92
|
+
|
|
93
|
+
# 移除注释后尝试JSON解析
|
|
94
|
+
content_without_comments = remove_comment(content)
|
|
95
|
+
try:
|
|
96
|
+
config = json_decode(content_without_comments)
|
|
97
|
+
except (ValueError, SyntaxError) as json_error:
|
|
98
|
+
# JSON解析失败,尝试AST解析
|
|
99
|
+
try:
|
|
100
|
+
config = literal_eval(content)
|
|
101
|
+
stdout.write("Successfully loaded config file with AST parser: %s\n" % config_path)
|
|
102
|
+
except (ValueError, SyntaxError) as ast_error:
|
|
103
|
+
if config_path.endswith(".json"):
|
|
104
|
+
stderr.write("JSON parsing failed for %s\n" % (config_path))
|
|
105
|
+
raise json_error
|
|
106
|
+
|
|
107
|
+
stderr.write(
|
|
108
|
+
"Both JSON and AST parsing failed for %s\nJSON Error: %s\nAST Error: %s\n"
|
|
109
|
+
% (config_path, json_error, ast_error)
|
|
110
|
+
)
|
|
111
|
+
raise ast_error
|
|
112
|
+
except Exception as e:
|
|
113
|
+
stderr.write("Failed to load config file `%s`: %s\n" % (config_path, e))
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
# 处理配置格式:v4.1 providers格式或单个对象
|
|
117
|
+
if "providers" in config and isinstance(config["providers"], list):
|
|
118
|
+
return _process_multi_providers(config)
|
|
119
|
+
else:
|
|
120
|
+
return _flatten_single_config(config)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def save_config(config_path, config):
|
|
124
|
+
# type: (str, dict) -> bool
|
|
125
|
+
"""
|
|
126
|
+
保存配置到文件。
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
config_path (str): 配置文件路径
|
|
130
|
+
config (dict): 配置字典
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
bool: 保存成功返回True
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
Exception: 保存失败时抛出异常
|
|
137
|
+
"""
|
|
138
|
+
# 补全默认配置
|
|
139
|
+
config = {
|
|
140
|
+
"$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
|
|
141
|
+
"dns": config.get("dns", "debug"),
|
|
142
|
+
"id": config.get("id", "YOUR ID or EMAIL for DNS Provider"),
|
|
143
|
+
"token": config.get("token", "YOUR TOKEN or KEY for DNS Provider"),
|
|
144
|
+
"ipv4": config.get("ipv4", ["ddns.newfuture.cc"]),
|
|
145
|
+
"index4": config.get("index4", ["default"]),
|
|
146
|
+
"ipv6": config.get("ipv6", []),
|
|
147
|
+
"index6": config.get("index6", []),
|
|
148
|
+
"ttl": config.get("ttl", 600),
|
|
149
|
+
"line": config.get("line"),
|
|
150
|
+
"proxy": config.get("proxy", []),
|
|
151
|
+
"cache": config.get("cache", True),
|
|
152
|
+
"ssl": config.get("ssl", "auto"),
|
|
153
|
+
"log": {
|
|
154
|
+
"file": config.get("log_file"),
|
|
155
|
+
"level": config.get("log_level", "INFO"),
|
|
156
|
+
"format": config.get("log_format"),
|
|
157
|
+
"datefmt": config.get("log_datefmt"),
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
try:
|
|
161
|
+
content = json_encode(config, indent=2, ensure_ascii=False)
|
|
162
|
+
# Python 2 兼容性:检查是否需要解码
|
|
163
|
+
if hasattr(content, "decode"):
|
|
164
|
+
content = content.decode("utf-8") # type: ignore
|
|
165
|
+
write_file(config_path, content)
|
|
166
|
+
return True
|
|
167
|
+
except Exception:
|
|
168
|
+
stderr.write("Cannot open config file to write: `%s`!\n" % config_path)
|
|
169
|
+
raise
|
ddns/provider/cloudflare.py
CHANGED
|
@@ -49,14 +49,33 @@ class CloudflareProvider(BaseProvider):
|
|
|
49
49
|
|
|
50
50
|
def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
|
|
51
51
|
# type: (str, str, str, str, str | None, dict) -> dict | None
|
|
52
|
-
"""
|
|
52
|
+
"""
|
|
53
|
+
查询DNS记录,优先使用extra filter匹配,匹配不到则fallback到不带extra的结果
|
|
54
|
+
|
|
55
|
+
Query DNS records, prioritize extra filters, fallback to query without extra if no match found.
|
|
56
|
+
https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/list/
|
|
57
|
+
"""
|
|
53
58
|
# cloudflare的域名查询需要完整域名
|
|
54
59
|
name = join_domain(subdomain, main_domain)
|
|
55
60
|
query = {"name.exact": name} # type: dict[str, str|None]
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
|
|
62
|
+
# 添加extra filter到查询参数,将布尔值转换为小写字符串
|
|
63
|
+
proxied = extra.get("proxied") if extra else None
|
|
64
|
+
if proxied is not None:
|
|
65
|
+
query["proxied"] = str(proxied).lower() # True -> "true", False -> "false"
|
|
66
|
+
|
|
67
|
+
# 先使用extra filter查询
|
|
58
68
|
data = self._request("GET", "/{}/dns_records".format(zone_id), type=record_type, per_page=10000, **query)
|
|
59
69
|
record = next((r for r in data if r.get("name") == name and r.get("type") == record_type), None)
|
|
70
|
+
|
|
71
|
+
# 如果使用了extra filter但没找到记录,尝试不带extra filter查询
|
|
72
|
+
if not record and proxied is not None:
|
|
73
|
+
self.logger.debug("No record found with extra filters, retrying without extra filters")
|
|
74
|
+
data = self._request(
|
|
75
|
+
"GET", "/{}/dns_records".format(zone_id), type=record_type, per_page=10000, **{"name.exact": name}
|
|
76
|
+
)
|
|
77
|
+
record = next((r for r in data if r.get("name") == name and r.get("type") == record_type), None)
|
|
78
|
+
|
|
60
79
|
self.logger.debug("Record queried: %s", record)
|
|
61
80
|
if record:
|
|
62
81
|
return record
|
|
@@ -81,9 +100,9 @@ class CloudflareProvider(BaseProvider):
|
|
|
81
100
|
# type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
|
|
82
101
|
"""https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/"""
|
|
83
102
|
extra["comment"] = extra.get("comment", self.remark) # 注释
|
|
84
|
-
extra["proxied"] =
|
|
85
|
-
extra["tags"] =
|
|
86
|
-
extra["settings"] =
|
|
103
|
+
extra["proxied"] = extra.get("proxied", old_record.get("proxied")) # extra优先,保持原有的代理状态作为默认值
|
|
104
|
+
extra["tags"] = extra.get("tags", old_record.get("tags")) # extra优先,保持原有的标签作为默认值
|
|
105
|
+
extra["settings"] = extra.get("settings", old_record.get("settings")) # extra优先,保持原有的设置作为默认值
|
|
87
106
|
data = self._request(
|
|
88
107
|
"PUT",
|
|
89
108
|
"/{}/dns_records/{}".format(zone_id, old_record["id"]),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Task scheduler management
|
|
4
|
+
Provides factory functions and public API for task scheduling
|
|
5
|
+
@author: NewFuture
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
|
|
11
|
+
from ddns.util.fileio import read_file_safely
|
|
12
|
+
|
|
13
|
+
from ..util.try_run import try_run
|
|
14
|
+
|
|
15
|
+
# Import all scheduler classes
|
|
16
|
+
from ._base import BaseScheduler
|
|
17
|
+
from .cron import CronScheduler
|
|
18
|
+
from .launchd import LaunchdScheduler
|
|
19
|
+
from .schtasks import SchtasksScheduler
|
|
20
|
+
from .systemd import SystemdScheduler
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_scheduler(scheduler=None):
|
|
24
|
+
# type: (str | None) -> BaseScheduler
|
|
25
|
+
"""
|
|
26
|
+
Factory function to get appropriate scheduler based on platform or user preference
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
scheduler: Scheduler type. Can be:
|
|
30
|
+
- None or "auto": Auto-detect based on platform
|
|
31
|
+
- "systemd": Use systemd timer (Linux)
|
|
32
|
+
- "cron": Use cron jobs (Unix/Linux)
|
|
33
|
+
- "launchd": Use launchd (macOS)
|
|
34
|
+
- "schtasks": Use Windows Task Scheduler
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Appropriate scheduler instance
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If invalid scheduler specified
|
|
41
|
+
NotImplementedError: If scheduler not available on current platform
|
|
42
|
+
"""
|
|
43
|
+
# Auto-detect if not specified
|
|
44
|
+
if scheduler is None or scheduler == "auto":
|
|
45
|
+
system = platform.system().lower()
|
|
46
|
+
if system == "windows":
|
|
47
|
+
return SchtasksScheduler()
|
|
48
|
+
elif system == "darwin": # macOS
|
|
49
|
+
# Check if launchd directories exist
|
|
50
|
+
launchd_dirs = ["/Library/LaunchDaemons", "/System/Library/LaunchDaemons"]
|
|
51
|
+
if any(os.path.isdir(d) for d in launchd_dirs):
|
|
52
|
+
return LaunchdScheduler()
|
|
53
|
+
elif system == "linux" and (
|
|
54
|
+
(read_file_safely("/proc/1/comm", default="").strip().lower() == "systemd")
|
|
55
|
+
or (try_run(["systemctl", "--version"]) is not None)
|
|
56
|
+
): # Linux with systemd available
|
|
57
|
+
return SystemdScheduler()
|
|
58
|
+
return CronScheduler() # Other Unix-like systems, use cron
|
|
59
|
+
elif scheduler == "systemd":
|
|
60
|
+
return SystemdScheduler()
|
|
61
|
+
elif scheduler == "cron":
|
|
62
|
+
return CronScheduler()
|
|
63
|
+
elif scheduler == "launchd" or scheduler == "mac":
|
|
64
|
+
return LaunchdScheduler()
|
|
65
|
+
elif scheduler == "schtasks" or scheduler == "windows":
|
|
66
|
+
return SchtasksScheduler()
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError("Invalid scheduler: {}. ".format(scheduler))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Export public API
|
|
72
|
+
__all__ = ["get_scheduler", "BaseScheduler"]
|
ddns/scheduler/_base.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# -*- coding:utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Base scheduler class for DDNS task management
|
|
4
|
+
@author: NewFuture
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from logging import Logger, getLogger # noqa: F401
|
|
10
|
+
|
|
11
|
+
from .. import __version__ as version
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseScheduler(object):
|
|
15
|
+
"""Base class for all task schedulers"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, logger=None): # type: (Logger | None) -> None
|
|
18
|
+
self.logger = (logger or getLogger()).getChild("task")
|
|
19
|
+
|
|
20
|
+
def _get_ddns_cmd(self): # type: () -> list[str]
|
|
21
|
+
"""Get DDNS command for scheduled execution as array"""
|
|
22
|
+
if hasattr(sys.modules["__main__"], "__compiled__"):
|
|
23
|
+
return [sys.argv[0]]
|
|
24
|
+
else:
|
|
25
|
+
return [sys.executable, "-m", "ddns"]
|
|
26
|
+
|
|
27
|
+
def _build_ddns_command(self, ddns_args=None): # type: (dict | None) -> list[str]
|
|
28
|
+
"""Build DDNS command with arguments as array"""
|
|
29
|
+
# Get base command as array
|
|
30
|
+
cmd_parts = self._get_ddns_cmd()
|
|
31
|
+
|
|
32
|
+
if not ddns_args:
|
|
33
|
+
return cmd_parts
|
|
34
|
+
|
|
35
|
+
# Filter out debug=False to reduce noise
|
|
36
|
+
args = {k: v for k, v in ddns_args.items() if not (k == "debug" and not v)}
|
|
37
|
+
|
|
38
|
+
for key, value in args.items():
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
cmd_parts.extend(["--{}".format(key), str(value).lower()])
|
|
41
|
+
elif isinstance(value, list):
|
|
42
|
+
for item in value:
|
|
43
|
+
cmd_parts.extend(["--{}".format(key), str(item)])
|
|
44
|
+
else:
|
|
45
|
+
cmd_parts.extend(["--{}".format(key), str(value)])
|
|
46
|
+
|
|
47
|
+
return cmd_parts
|
|
48
|
+
|
|
49
|
+
def _quote_command_array(self, cmd_array): # type: (list[str]) -> str
|
|
50
|
+
"""Convert command array to properly quoted command string"""
|
|
51
|
+
return " ".join('"{}"'.format(arg) if " " in arg else arg for arg in cmd_array)
|
|
52
|
+
|
|
53
|
+
def _get_description(self): # type: () -> str
|
|
54
|
+
"""Generate standard description/comment for DDNS installation"""
|
|
55
|
+
date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
56
|
+
return "auto-update v{} installed on {}".format(version, date)
|
|
57
|
+
|
|
58
|
+
def is_installed(self): # type: () -> bool
|
|
59
|
+
"""Check if DDNS task is installed"""
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def get_status(self): # type: () -> dict
|
|
63
|
+
"""Get detailed status information"""
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def install(self, interval, ddns_args=None): # type: (int, dict | None) -> bool
|
|
67
|
+
"""Install DDNS scheduled task"""
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
def uninstall(self): # type: () -> bool
|
|
71
|
+
"""Uninstall DDNS scheduled task"""
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
def enable(self): # type: () -> bool
|
|
75
|
+
"""Enable DDNS scheduled task"""
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def disable(self): # type: () -> bool
|
|
79
|
+
"""Disable DDNS scheduled task"""
|
|
80
|
+
raise NotImplementedError
|