ddns 4.0.2__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/_base.py ADDED
@@ -0,0 +1,659 @@
1
+ # coding=utf-8
2
+ """
3
+ ## SimpleProvider 简单DNS抽象基类
4
+
5
+ * set_record()
6
+
7
+ ## BaseProvider 标准DNS抽象基类
8
+ 定义所有 DNS 服务商 API 类应继承的抽象基类,统一接口,便于扩展适配多服务商。
9
+
10
+ Abstract base class for DNS provider APIs.
11
+ Defines a unified interface to support extension and adaptation across providers.
12
+ * _query_zone_id
13
+ * _query_record
14
+ * _update_record
15
+ * _create_record
16
+ ┌──────────────────────────────────────────────────┐
17
+ │ 用户调用 set_record(domain, value...) │
18
+ └──────────────────────────────────────────────────┘
19
+
20
+
21
+ ┌──────────────────────────────────────┐
22
+ │ 快速解析 是否包含 ~ 或 + 分隔符? │
23
+ └──────────────────────────────────────┘
24
+ │ │
25
+ [是,拆解成功] [否,无法拆解]
26
+ sub 和 main│ │ domain
27
+ ▼ ▼
28
+ ┌────────────────────────┐ ┌──────────────────────────┐
29
+ │ 查询 zone_id │ │ 自动循环解析 while: │
30
+ │ _query_zone_id(main) │ │ _query_zone_id(...) │
31
+ └────────────────────────┘ └──────────────────────────┘
32
+ │ │
33
+ ▼ ▼
34
+ zone_id ←──────────────┬─── sub
35
+
36
+ ┌─────────────────────────────────────┐
37
+ │ 查询 record: │
38
+ │ _query_record(zone_id, sub, ...) │
39
+ └─────────────────────────────────────┘
40
+
41
+ ┌─────────────┴────────────────┐
42
+ │ record_id 是否存在? │
43
+ └────────────┬─────────────────┘
44
+
45
+ ┌──────────────┴─────────────┐
46
+ │ │
47
+ ▼ ▼
48
+ ┌─────────────────────┐ ┌─────────────────────┐
49
+ │ 更新记录 │ │ 创建记录 │
50
+ │ _update_record(...) │ │ _create_record(...) │
51
+ └─────────────────────┘ └─────────────────────┘
52
+ │ │
53
+ ▼ ▼
54
+ ┌───────────────────────────────┐
55
+ │ 返回操作结果 │
56
+ └───────────────────────────────┘
57
+ @author: NewFuture
58
+ """
59
+
60
+ from abc import ABCMeta, abstractmethod
61
+ from hashlib import sha256
62
+ from hmac import HMAC
63
+ from json import loads as jsondecode, dumps as jsonencode
64
+ from logging import Logger, getLogger # noqa:F401 # type: ignore[no-redef]
65
+ from os import environ
66
+ from ..util.http import send_http_request
67
+
68
+ try: # python 3
69
+ from urllib.parse import quote, urlencode
70
+ except ImportError: # python 2
71
+ from urllib import urlencode, quote # type: ignore[no-redef,import-untyped]
72
+
73
+ TYPE_FORM = "application/x-www-form-urlencoded"
74
+ TYPE_JSON = "application/json"
75
+
76
+
77
+ def hmac_sha256(key, message):
78
+ # type: (str | bytes, str | bytes) -> HMAC
79
+ """
80
+ 计算 HMAC-SHA256 签名对象
81
+
82
+ Compute HMAC-SHA256 signature object.
83
+
84
+ Args:
85
+ key (str | bytes): 签名密钥 / Signing key
86
+ message (str | bytes): 待签名消息 / Message to sign
87
+
88
+ Returns:
89
+ HMAC: HMAC签名对象,可调用.digest()获取字节或.hexdigest()获取十六进制字符串
90
+ HMAC signature object, call .digest() for bytes or .hexdigest() for hex string
91
+ """
92
+ # Python 2/3 compatible encoding - avoid double encoding in Python 2
93
+ if not isinstance(key, bytes):
94
+ key = key.encode("utf-8")
95
+ if not isinstance(message, bytes):
96
+ message = message.encode("utf-8")
97
+ return HMAC(key, message, sha256)
98
+
99
+
100
+ def sha256_hash(data):
101
+ # type: (str | bytes) -> str
102
+ """
103
+ 计算 SHA256 哈希值
104
+
105
+ Compute SHA256 hash.
106
+
107
+ Args:
108
+ data (str | bytes): 待哈希数据 / Data to hash
109
+
110
+ Returns:
111
+ str: 十六进制哈希字符串 / Hexadecimal hash string
112
+ """
113
+ # Python 2/3 compatible encoding - avoid double encoding in Python 2
114
+ if not isinstance(data, bytes):
115
+ data = data.encode("utf-8")
116
+ return sha256(data).hexdigest()
117
+
118
+
119
+ def hmac_sha256_authorization(
120
+ secret_key, # type: str | bytes
121
+ method, # type: str
122
+ path, # type: str
123
+ query, # type: str
124
+ headers, # type: dict[str, str]
125
+ body_hash, # type: str
126
+ signing_string_format, # type: str
127
+ authorization_format, # type: str
128
+ ):
129
+ # type: (...) -> str
130
+ """
131
+ HMAC-SHA256 云服务商通用认证签名生成器
132
+
133
+ Universal cloud provider authentication signature generator using HMAC-SHA256.
134
+
135
+ 通用的云服务商API认证签名生成函数,使用HMAC-SHA256算法生成符合各云服务商规范的Authorization头部。
136
+
137
+ 模板变量格式:{HashedCanonicalRequest}, {SignedHeaders}, {Signature}
138
+
139
+ Args:
140
+ secret_key (str | bytes): 签名密钥,已经过密钥派生处理 / Signing key (already derived if needed)
141
+ method (str): HTTP请求方法 / HTTP request method (GET, POST, etc.)
142
+ path (str): API请求路径 / API request path
143
+ query (str): URL查询字符串 / URL query string
144
+ headers (dict[str, str]): HTTP请求头部 / HTTP request headers
145
+ body_hash (str): 请求体的SHA256哈希值 / SHA256 hash of request payload
146
+ signing_string_format (str): 待签名字符串模板,包含 {HashedCanonicalRequest} 占位符
147
+ authorization_format (str): Authorization头部模板,包含 {SignedHeaders}, {Signature} 占位符
148
+
149
+ Returns:
150
+ str: 完整的Authorization头部值 / Complete Authorization header value
151
+ """
152
+ # 1. 构建规范化头部 - 所有传入的头部都参与签名
153
+ headers_to_sign = {k.lower(): str(v).strip() for k, v in headers.items()}
154
+ signed_headers_list = sorted(headers_to_sign.keys())
155
+
156
+ # 2. 构建规范请求字符串
157
+ canonical_headers = ""
158
+ for header_name in signed_headers_list:
159
+ canonical_headers += "{}:{}\n".format(header_name, headers_to_sign[header_name])
160
+
161
+ # 构建完整的规范请求字符串
162
+ signed_headers = ";".join(signed_headers_list)
163
+ canonical_request = "\n".join([method.upper(), path, query, canonical_headers, signed_headers, body_hash])
164
+
165
+ # 3. 构建待签名字符串 - 只需要替换 HashedCanonicalRequest
166
+ hashed_canonical_request = sha256_hash(canonical_request)
167
+ string_to_sign = signing_string_format.format(HashedCanonicalRequest=hashed_canonical_request)
168
+
169
+ # 4. 计算最终签名
170
+ signature = hmac_sha256(secret_key, string_to_sign).hexdigest()
171
+
172
+ # 5. 构建Authorization头部 - 只需要替换 SignedHeaders 和 Signature
173
+ authorization = authorization_format.format(SignedHeaders=signed_headers, Signature=signature)
174
+
175
+ return authorization
176
+
177
+
178
+ class SimpleProvider(object):
179
+ """
180
+ 简单DNS服务商接口的抽象基类, 必须实现 `set_record` 方法。
181
+
182
+ Abstract base class for all simple DNS provider APIs.
183
+ Subclasses must implement `set_record`.
184
+
185
+ * set_record(domain, value, record_type="A", ttl=None, line=None, **extra)
186
+ """
187
+
188
+ __metaclass__ = ABCMeta
189
+
190
+ # API endpoint domain (to be defined in subclass)
191
+ API = "" # type: str # https://exampledns.com
192
+ # Content-Type for requests (to be defined in subclass)
193
+ content_type = TYPE_FORM # type: Literal["application/x-www-form-urlencoded"] | Literal["application/json"]
194
+ # 默认 accept 头部, 空则不设置
195
+ accept = TYPE_JSON # type: str | None
196
+ # Decode Response as JSON by default
197
+ decode_response = True
198
+ # 是否验证 SSL 证书,默认为 True
199
+ verify_ssl = "auto" # type: bool | str
200
+
201
+ # 版本
202
+ version = environ.get("DDNS_VERSION", "0.0.0")
203
+ # Description
204
+ remark = "Managed by [DDNS v{}](https://ddns.newfuture.cc)".format(version)
205
+
206
+ def __init__(self, auth_id, auth_token, logger=None, verify_ssl=None, **options):
207
+ # type: (str, str, Logger | None, bool|str| None, **object) -> None
208
+ """
209
+ 初始化服务商对象
210
+
211
+ Initialize provider instance.
212
+
213
+ Args:
214
+ auth_id (str): 身份认证 ID / Authentication ID
215
+ auth_token (str): 密钥 / Authentication Token
216
+ options (dict): 其它参数,如代理、调试等 / Additional options
217
+ """
218
+ self.auth_id = auth_id # type: str
219
+ self.auth_token = auth_token # type: str
220
+ self.options = options
221
+ name = self.__class__.__name__
222
+ self.logger = (logger or getLogger()).getChild(name)
223
+ self.proxy = None # type: str | None
224
+ if verify_ssl is not None:
225
+ self.verify_ssl = verify_ssl
226
+ self._zone_map = {} # type: dict[str, str]
227
+ self.logger.debug("%s initialized with: %s", self.__class__.__name__, auth_id)
228
+ self._validate() # 验证身份认证信息
229
+
230
+ @abstractmethod
231
+ def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
232
+ # type: (str, str, str, str | int | None, str | None, **object) -> bool
233
+ """
234
+ 设置 DNS 记录(创建或更新)
235
+
236
+ Set or update DNS record.
237
+
238
+ Args:
239
+ domain (str): 完整域名
240
+ value (str): 新记录值
241
+ record_type (str): 记录类型
242
+ ttl (int | None): TTL 值,可选
243
+ line (str | None): 线路信息
244
+ extra (dict): 额外参数
245
+
246
+ Returns:
247
+ Any: 执行结果
248
+ """
249
+ raise NotImplementedError("This set_record should be implemented by subclasses")
250
+
251
+ def set_proxy(self, proxy_str):
252
+ # type: (str | None) -> SimpleProvider
253
+ """
254
+ 设置代理服务器
255
+
256
+ Set HTTPS proxy string.
257
+
258
+ Args:
259
+ proxy_str (str): 代理地址
260
+
261
+ Returns:
262
+ Self: 自身
263
+ """
264
+ self.proxy = proxy_str
265
+ return self
266
+
267
+ def _validate(self):
268
+ # type: () -> None
269
+ """
270
+ 验证身份认证信息是否填写
271
+
272
+ Validate authentication credentials.
273
+ """
274
+ if not self.auth_id:
275
+ raise ValueError("id must be configured")
276
+ if not self.auth_token:
277
+ raise ValueError("token must be configured")
278
+ if not self.API:
279
+ raise ValueError("API endpoint must be defined in {}".format(self.__class__.__name__))
280
+
281
+ def _http(self, method, url, params=None, body=None, queries=None, headers=None): # noqa: C901
282
+ # type: (str, str, dict[str,Any]|str|None, dict[str,Any]|str|None, dict[str,Any]|None, dict|None) -> Any
283
+ """
284
+ 发送 HTTP/HTTPS 请求,自动根据 API/url 选择协议。
285
+
286
+ Args:
287
+ method (str): 请求方法,如 GET、POST
288
+ url (str): 请求路径
289
+ params (dict[str, Any] | None): 请求参数,自动处理 query string 或者body
290
+ body (dict[str, Any] | str | None): 请求体内容
291
+ queries (dict[str, Any] | None): 查询参数,自动处理为 URL 查询字符串
292
+ headers (dict): 头部,可选
293
+
294
+ Returns:
295
+ Any: 解析后的响应内容
296
+
297
+ Raises:
298
+ RuntimeError: 当响应状态码为400/401或5xx(服务器错误)时抛出异常
299
+ """
300
+ method = method.upper()
301
+
302
+ # 简化参数处理逻辑
303
+ query_params = queries or {}
304
+ if params:
305
+ if method in ("GET", "DELETE"):
306
+ if isinstance(params, dict):
307
+ query_params.update(params)
308
+ else:
309
+ # params是字符串,直接作为查询字符串
310
+ url += ("&" if "?" in url else "?") + str(params)
311
+ params = None
312
+ elif body is None:
313
+ body = params
314
+
315
+ # 构建查询字符串
316
+ if len(query_params) > 0:
317
+ url += ("&" if "?" in url else "?") + self._encode(query_params)
318
+
319
+ # 构建完整URL
320
+ if not url.startswith("http://") and not url.startswith("https://"):
321
+ if not url.startswith("/") and self.API.endswith("/"):
322
+ url = "/" + url
323
+ url = self.API + url
324
+
325
+ # 记录请求日志
326
+ self.logger.info("%s %s", method, self._mask_sensitive_data(url))
327
+
328
+ # 处理请求体
329
+ body_data, headers = None, headers or {}
330
+ if body:
331
+ if "content-type" not in headers:
332
+ headers["content-type"] = self.content_type
333
+ if isinstance(body, (str, bytes)):
334
+ body_data = body
335
+ elif self.content_type == TYPE_FORM:
336
+ body_data = self._encode(body)
337
+ else:
338
+ body_data = jsonencode(body)
339
+ self.logger.debug("body:\n%s", self._mask_sensitive_data(body_data))
340
+
341
+ # 处理headers
342
+ if self.accept and "accept" not in headers and "Accept" not in headers:
343
+ headers["accept"] = self.accept
344
+ if len(headers) > 2:
345
+ self.logger.debug("headers:\n%s", {k: self._mask_sensitive_data(v) for k, v in headers.items()})
346
+
347
+ response = send_http_request(
348
+ url=url,
349
+ method=method,
350
+ body=body_data,
351
+ headers=headers,
352
+ proxy=self.proxy,
353
+ max_redirects=5,
354
+ verify_ssl=self.verify_ssl,
355
+ )
356
+
357
+ # 处理响应
358
+ status_code = response.status
359
+ if not (200 <= status_code < 300):
360
+ self.logger.warning("response status: %s %s", status_code, response.reason)
361
+
362
+ res = response.body
363
+ # 针对客户端错误、认证/授权错误和服务器错误直接抛出异常
364
+ if status_code >= 500 or status_code in (400, 401, 403):
365
+ self.logger.error("HTTP error:\n%s", res)
366
+ if status_code == 400:
367
+ raise RuntimeError("请求参数错误 [400]: " + response.reason)
368
+ elif status_code == 401:
369
+ raise RuntimeError("认证失败 [401]: " + response.reason)
370
+ elif status_code == 403:
371
+ raise RuntimeError("权限不足 [403]: " + response.reason)
372
+ else:
373
+ raise RuntimeError("服务器错误 [{}]: {}".format(status_code, response.reason))
374
+
375
+ self.logger.debug("response:\n%s", res)
376
+ if not self.decode_response:
377
+ return res
378
+
379
+ try:
380
+ return jsondecode(res)
381
+ except Exception as e:
382
+ self.logger.error("fail to decode response: %s", e)
383
+ return res
384
+
385
+ @staticmethod
386
+ def _encode(params):
387
+ # type: (dict|list|str|bytes|None) -> str
388
+ """
389
+ 编码参数为 URL 查询字符串
390
+
391
+ Args:
392
+ params (dict|list|str|bytes|None): 参数字典、列表或字符串
393
+ Returns:
394
+ str: 编码后的查询字符串
395
+ """
396
+ if not params:
397
+ return ""
398
+ elif isinstance(params, (str, bytes)):
399
+ return params # type: ignore[return-value]
400
+ return urlencode(params, doseq=True)
401
+
402
+ @staticmethod
403
+ def _quote(data, safe="/"):
404
+ # type: (str, str) -> str
405
+ """
406
+ 对字符串进行 URL 编码
407
+
408
+ Args:
409
+ data (str): 待编码字符串
410
+
411
+ Returns:
412
+ str: 编码后的字符串
413
+ """
414
+ return quote(data, safe=safe)
415
+
416
+ def _mask_sensitive_data(self, data):
417
+ # type: (str | bytes | None) -> str | bytes | None
418
+ """
419
+ 对敏感数据进行打码处理,用于日志输出,支持URL编码的敏感信息
420
+
421
+ Args:
422
+ data (str | bytes | None): 需要处理的数据
423
+ Returns:
424
+ str | bytes | None: 打码后的字符串
425
+ """
426
+ if not data or not self.auth_token:
427
+ return data
428
+
429
+ # 生成打码后的token
430
+ token_masked = self.auth_token[:2] + "***" + self.auth_token[-2:] if len(self.auth_token) > 4 else "***"
431
+ token_encoded = quote(self.auth_token, safe="")
432
+
433
+ if isinstance(data, bytes): # 处理字节数据
434
+ return data.replace(self.auth_token.encode(), token_masked.encode()).replace(
435
+ token_encoded.encode(), token_masked.encode()
436
+ )
437
+ if hasattr(data, "replace"): # 处理字符串数据
438
+ return data.replace(self.auth_token, token_masked).replace(token_encoded, token_masked)
439
+ return data
440
+
441
+
442
+ class BaseProvider(SimpleProvider):
443
+ """
444
+ 标准DNS服务商接口的抽象基类
445
+
446
+ Abstract base class for all standard DNS provider APIs.
447
+ Subclasses must implement the abstract methods to support various providers.
448
+
449
+ * _query_zone_id()
450
+ * _query_record_id()
451
+ * _update_record()
452
+ * _create_record()
453
+ """
454
+
455
+ def set_record(self, domain, value, record_type="A", ttl=None, line=None, **extra):
456
+ # type: (str, str, str, str | int | None, str | None, **Any) -> bool
457
+ """
458
+ 设置 DNS 记录(创建或更新)
459
+
460
+ Set or update DNS record.
461
+
462
+ Args:
463
+ domain (str): 完整域名
464
+ value (str): 新记录值
465
+ record_type (str): 记录类型
466
+ ttl (int | None): TTL 值,可选
467
+ line (str | None): 线路信息
468
+ extra (dict): 额外参数
469
+
470
+ Returns:
471
+ bool: 执行结果
472
+ """
473
+ domain = domain.lower()
474
+ self.logger.info("%s => %s(%s)", domain, value, record_type)
475
+ sub, main = split_custom_domain(domain)
476
+ try:
477
+ if sub is not None:
478
+ # 使用自定义分隔符格式
479
+ zone_id = self.get_zone_id(main)
480
+ else:
481
+ # 自动分析域名
482
+ zone_id, sub, main = self._split_zone_and_sub(domain)
483
+
484
+ self.logger.info("sub: %s, main: %s(id=%s)", sub, main, zone_id)
485
+ if not zone_id or sub is None:
486
+ self.logger.critical("找不到 zone_id 或 subdomain: %s", domain)
487
+ return False
488
+
489
+ # 查询现有记录
490
+ record = self._query_record(zone_id, sub, main, record_type=record_type, line=line, extra=extra)
491
+
492
+ # 更新或创建记录
493
+ if record:
494
+ self.logger.info("Found existing record: %s", record)
495
+ return self._update_record(zone_id, record, value, record_type, ttl=ttl, line=line, extra=extra)
496
+ else:
497
+ self.logger.warning("No existing record found, creating new one")
498
+ return self._create_record(zone_id, sub, main, value, record_type, ttl=ttl, line=line, extra=extra)
499
+ except Exception as e:
500
+ self.logger.exception("Error setting record for %s: %s", domain, e)
501
+ return False
502
+
503
+ def get_zone_id(self, domain):
504
+ # type: (str) -> str | None
505
+ """
506
+ 查询指定域名对应的 zone_id
507
+
508
+ Get zone_id for the domain.
509
+
510
+ Args:
511
+ domain (str): 主域名 / main name
512
+
513
+ Returns:
514
+ str | None: 区域 ID / Zone identifier
515
+ """
516
+ if domain in self._zone_map:
517
+ return self._zone_map[domain]
518
+ zone_id = self._query_zone_id(domain)
519
+ if zone_id:
520
+ self._zone_map[domain] = zone_id
521
+ return zone_id
522
+
523
+ @abstractmethod
524
+ def _query_zone_id(self, domain):
525
+ # type: (str) -> str | None
526
+ """
527
+ 查询主域名的 zone ID
528
+
529
+ Args:
530
+ domain (str): 主域名
531
+
532
+ Returns:
533
+ str | None: Zone ID
534
+ """
535
+ return domain
536
+
537
+ @abstractmethod
538
+ def _query_record(self, zone_id, subdomain, main_domain, record_type, line, extra):
539
+ # type: (str, str, str, str, str | None, dict) -> Any
540
+ """
541
+ 查询 DNS 记录 ID
542
+
543
+ Args:
544
+ zone_id (str): 区域 ID
545
+ subdomain (str): 子域名
546
+ main_domain (str): 主域名
547
+ record_type (str): 记录类型,例如 A、AAAA
548
+ line (str | None): 线路选项,可选
549
+ extra (dict): 额外参数
550
+ Returns:
551
+ Any | None: 记录
552
+ """
553
+ raise NotImplementedError("This _query_record should be implemented by subclasses")
554
+
555
+ @abstractmethod
556
+ def _create_record(self, zone_id, subdomain, main_domain, value, record_type, ttl, line, extra):
557
+ # type: (str, str, str, str, str, int | str | None, str | None, dict) -> bool
558
+ """
559
+ 创建新 DNS 记录
560
+
561
+ Args:
562
+ zone_id (str): 区域 ID
563
+ subdomain (str): 子域名
564
+ main_domain (str): 主域名
565
+ value (str): 记录值
566
+ record_type (str): 类型,如 A
567
+ ttl (int | None): TTL 可选
568
+ line (str | None): 线路选项
569
+ extra (dict | None): 额外字段
570
+
571
+ Returns:
572
+ Any: 操作结果
573
+ """
574
+ raise NotImplementedError("This _create_record should be implemented by subclasses")
575
+
576
+ @abstractmethod
577
+ def _update_record(self, zone_id, old_record, value, record_type, ttl, line, extra):
578
+ # type: (str, dict, str, str, int | str | None, str | None, dict) -> bool
579
+ """
580
+ 更新已有 DNS 记录
581
+
582
+ Args:
583
+ zone_id (str): 区域 ID
584
+ old_record (dict): 旧记录信息
585
+ value (str): 新的记录值
586
+ record_type (str): 类型
587
+ ttl (int | None): TTL
588
+ line (str | None): 线路
589
+ extra (dict | None): 额外参数
590
+
591
+ Returns:
592
+ bool: 操作结果
593
+ """
594
+ raise NotImplementedError("This _update_record should be implemented by subclasses")
595
+
596
+ def _split_zone_and_sub(self, domain):
597
+ # type: (str) -> tuple[str | None, str | None, str ]
598
+ """
599
+ 从完整域名拆分主域名和子域名
600
+
601
+ Args:
602
+ domain (str): 完整域名
603
+
604
+ Returns:
605
+ (zone_id, sub): 元组
606
+ """
607
+ domain_split = domain.split(".")
608
+ zone_id = None
609
+ index = 2
610
+ main = ""
611
+ while not zone_id and index <= len(domain_split):
612
+ main = ".".join(domain_split[-index:])
613
+ zone_id = self.get_zone_id(main)
614
+ index += 1
615
+ if zone_id:
616
+ sub = ".".join(domain_split[: -index + 1]) or "@"
617
+ self.logger.debug("zone_id: %s, sub: %s", zone_id, sub)
618
+ return zone_id, sub, main
619
+ return None, None, main
620
+
621
+
622
+ def split_custom_domain(domain):
623
+ # type: (str) -> tuple[str | None, str]
624
+ """
625
+ 拆分支持 ~ 或 + 的自定义格式域名为 (子域, 主域)
626
+
627
+ 如 sub~example.com => ('sub', 'example.com')
628
+
629
+ Returns:
630
+ (sub, main): 子域 + 主域
631
+ """
632
+ for sep in ("~", "+"):
633
+ if sep in domain:
634
+ sub, main = domain.split(sep, 1)
635
+ return sub, main
636
+ return None, domain
637
+
638
+
639
+ def join_domain(sub, main):
640
+ # type: (str | None, str) -> str
641
+ """
642
+ 合并子域名和主域名为完整域名
643
+
644
+ Args:
645
+ sub (str | None): 子域名
646
+ main (str): 主域名
647
+
648
+ Returns:
649
+ str: 完整域名
650
+ """
651
+ sub = sub and sub.strip(".").strip().lower()
652
+ main = main and main.strip(".").strip().lower()
653
+ if not sub or sub == "@":
654
+ if not main:
655
+ raise ValueError("Both sub and main cannot be empty")
656
+ return main
657
+ if not main:
658
+ return sub
659
+ return "{}.{}".format(sub, main)