Files
archived-MoviePilot-Plugins/plugins/alidnsddns/__init__.py
DTZSGHNR a398dcb0b8 fix(alidnsddns): 根据 Code Review 建议修复四处问题
- 移除 init_plugin 中重复的 interval 调度,定时任务统一由宿主 get_service() 管理
- 将废弃的 datetime.utcnow() 替换为 datetime.now(timezone.utc)
- API 请求增加 HTTPError/URLError 捕获,读取响应体输出详细错误信息
- upsert() 改为更新所有匹配记录,而不只取第一条
2026-04-26 07:05:57 +08:00

624 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import hashlib
import hmac
import random
import socket
import string
import urllib.error
import urllib.parse
import urllib.request
from base64 import b64encode
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from app.core.config import settings
from app.log import logger
from app.plugins import _PluginBase
from app.schemas.types import NotificationType
_MAX_HISTORY = 100
class AliDnsDDNS(_PluginBase):
# ──────────────────────────────────────────────
# 插件元数据
# ──────────────────────────────────────────────
plugin_name = "阿里云 DDNS"
plugin_desc = "定时检测公网 IP自动更新阿里云 DNS 解析记录,支持泛域名(* 记录)及 IPv6AAAA"
plugin_icon = "AliDnsDDNS.png"
plugin_version = "1.0"
plugin_author = "dtzsghnr"
author_url = "https://github.com/dtzsghnr"
plugin_config_prefix = "alidnsddns_"
plugin_order = 30
auth_level = 1
# ──────────────────────────────────────────────
# 私有状态
# ──────────────────────────────────────────────
_enabled: bool = False
_access_key_id: str = ""
_access_key_secret: str = ""
_records: str = ""
_interval: int = 5
_notify: bool = True
_run_once: bool = False
_scheduler: Optional[BackgroundScheduler] = None
_last_ipv4: str = ""
_last_ipv6: str = ""
# ──────────────────────────────────────────────
# 生命周期
# ──────────────────────────────────────────────
def init_plugin(self, config: dict = None):
if config:
self._enabled = config.get("enabled", False)
self._access_key_id = config.get("access_key_id", "").strip()
self._access_key_secret = config.get("access_key_secret", "").strip()
self._records = config.get("records", "").strip()
self._interval = max(1, int(config.get("interval", 5) or 5))
self._notify = config.get("notify", True)
self._run_once = config.get("run_once", False)
self.stop_service()
if not self._enabled:
return
record_count = len(_parse_records(self._records))
logger.info(
f"[AliDnsDDNS] 插件已启动 | 检测间隔: {self._interval}min | 记录数: {record_count}"
)
# 立即执行一次:用独立调度器触发,不与宿主调度器冲突
if self._run_once:
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
self._scheduler.add_job(
func=self.__update_dns,
trigger="date",
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
name="阿里云DDNS 立即执行",
)
self._scheduler.start()
self._run_once = False
self.update_config({
"enabled": self._enabled,
"access_key_id": self._access_key_id,
"access_key_secret": self._access_key_secret,
"records": self._records,
"interval": self._interval,
"notify": self._notify,
"run_once": False,
})
def get_state(self) -> bool:
return self._enabled
def stop_service(self):
try:
if self._scheduler:
self._scheduler.remove_all_jobs()
if self._scheduler.running:
self._scheduler.shutdown()
self._scheduler = None
except Exception as e:
logger.error(f"[AliDnsDDNS] 停止调度器失败: {e}")
# ──────────────────────────────────────────────
# 服务注册(宿主调度器)
# ──────────────────────────────────────────────
def get_service(self) -> List[Dict[str, Any]]:
if self._enabled and self._interval:
return [{
"id": "AliDnsDDNS",
"name": "阿里云 DDNS 更新",
"trigger": IntervalTrigger(minutes=self._interval),
"func": self.__update_dns,
"kwargs": {},
}]
return []
def get_command(self) -> List[Dict[str, Any]]:
return []
def get_api(self) -> List[Dict[str, Any]]:
return [
{
"path": "/alidnsddns/history",
"endpoint": self.__api_history,
"methods": ["GET"],
"summary": "获取 DDNS 更新历史",
},
{
"path": "/alidnsddns/history/clear",
"endpoint": self.__api_clear_history,
"methods": ["POST"],
"summary": "清空 DDNS 更新历史",
},
]
# ──────────────────────────────────────────────
# API
# ──────────────────────────────────────────────
def __api_history(self) -> List[dict]:
return self.get_data("history") or []
def __api_clear_history(self) -> dict:
self.del_data("history")
logger.info("[AliDnsDDNS] 更新历史已清空")
return {"success": True}
# ──────────────────────────────────────────────
# 配置表单
# ──────────────────────────────────────────────
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
return [
{
"component": "VForm",
"content": [
# ── 开关行 ──
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [{
"component": "VSwitch",
"props": {"model": "enabled", "label": "启用插件"},
}],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [{
"component": "VSwitch",
"props": {"model": "notify", "label": "IP 变化时发送通知"},
}],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [{
"component": "VSwitch",
"props": {"model": "run_once", "label": "立即运行一次"},
}],
},
],
},
# ── 密钥行 ──
{
"component": "VRow",
"content": [
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [{
"component": "VTextField",
"props": {
"model": "access_key_id",
"label": "AccessKey ID",
"placeholder": "LTAI5t...",
"hint": "阿里云 RAM 访问密钥 ID",
},
}],
},
{
"component": "VCol",
"props": {"cols": 12, "md": 6},
"content": [{
"component": "VTextField",
"props": {
"model": "access_key_secret",
"label": "AccessKey Secret",
"placeholder": "xxxx...",
"type": "password",
"hint": "阿里云 RAM 访问密钥 Secret",
},
}],
},
],
},
# ── 间隔 ──
{
"component": "VRow",
"content": [{
"component": "VCol",
"props": {"cols": 12, "md": 4},
"content": [{
"component": "VTextField",
"props": {
"model": "interval",
"label": "检测间隔(分钟)",
"type": "number",
"placeholder": "5",
"hint": "最小 1 分钟",
},
}],
}],
},
# ── 记录列表 ──
{
"component": "VRow",
"content": [{
"component": "VCol",
"props": {"cols": 12},
"content": [{
"component": "VTextarea",
"props": {
"model": "records",
"label": "DNS 记录列表",
"rows": 6,
"placeholder": (
"格式:顶级域名 主机记录 类型类型可省略默认A\n"
"example.com @ A # example.com 根域 IPv4\n"
"example.com * A # *.example.com 泛域名 IPv4\n"
"example.com home A # home.example.com IPv4\n"
"example.com home AAAA # home.example.com IPv6"
),
"hint": "第一列:阿里云注册的顶级域(如 example.com第二列主机记录前缀@ 根域 / * 泛域名 / 子域名前缀)",
"persistent-hint": True,
},
}],
}],
},
# ── 说明 ──
{
"component": "VRow",
"content": [{
"component": "VCol",
"props": {"cols": 12},
"content": [{
"component": "VAlert",
"props": {
"type": "info",
"variant": "tonal",
"text": (
"需要在阿里云 RAM 控制台为 AccessKey 授予 AliyunDNSFullAccess 权限。"
"第一列必须是阿里云中托管的顶级域(如 example.com第二列是主机记录前缀。"
"更新 home.example.com 填example.com home A。"
"泛域名填 *,根域填 @IPv6 类型填 AAAA。"
),
},
}],
}],
},
],
}
], {
"enabled": False,
"access_key_id": "",
"access_key_secret": "",
"records": "",
"interval": 5,
"notify": True,
"run_once": False,
}
# ──────────────────────────────────────────────
# 详情页
# ──────────────────────────────────────────────
def get_page(self) -> List[dict]:
history: List[dict] = self.get_data("history") or []
if not history:
return [{
"component": "div",
"props": {"class": "text-center pa-6 text-medium-emphasis"},
"text": "暂无更新记录",
}]
history = sorted(history, key=lambda x: x.get("update_time", ""), reverse=True)
return [{
"component": "VDataTable",
"props": {
"headers": [
{"title": "域名", "key": "fqdn", "sortable": True},
{"title": "类型", "key": "type", "sortable": True, "width": "80px"},
{"title": "IP 地址", "key": "ip", "sortable": False},
{"title": "更新时间", "key": "update_time", "sortable": True},
],
"items": history,
"density": "comfortable",
"hover": True,
"items-per-page": 20,
},
}]
# ──────────────────────────────────────────────
# 核心逻辑
# ──────────────────────────────────────────────
def __update_dns(self):
if not self._access_key_id or not self._access_key_secret:
logger.warning("[AliDnsDDNS] AccessKey 未配置,跳过本次检测")
return
parsed = _parse_records(self._records)
if not parsed:
logger.warning("[AliDnsDDNS] 记录列表为空,跳过本次检测")
return
need_v4 = any(r["type"] == "A" for r in parsed)
need_v6 = any(r["type"] == "AAAA" for r in parsed)
ipv4 = _get_public_ip(v6=False) if need_v4 else None
ipv6 = _get_public_ip(v6=True) if need_v6 else None
if need_v4 and not ipv4:
logger.error("[AliDnsDDNS] 公网 IPv4 获取失败,跳过本次更新")
return
if need_v6 and not ipv6:
logger.error("[AliDnsDDNS] 公网 IPv6 获取失败,跳过本次更新")
return
client = _AliDnsClient(self._access_key_id, self._access_key_secret)
updated: List[dict] = []
now_str = datetime.now(tz=pytz.timezone(settings.TZ)).strftime("%Y-%m-%d %H:%M:%S")
for rec in parsed:
ip = ipv4 if rec["type"] == "A" else ipv6
fqdn = _fqdn(rec["rr"], rec["domain"])
try:
changed = client.upsert(rec["domain"], rec["rr"], rec["type"], ip)
if changed:
updated.append({"fqdn": fqdn, "type": rec["type"],
"ip": ip, "update_time": now_str})
logger.info(
f"[AliDnsDDNS] 记录已更新 | {fqdn} | {rec['type']} | {ip}"
)
else:
logger.debug(
f"[AliDnsDDNS] 记录无变化 | {fqdn} | {rec['type']} | {ip}"
)
except Exception as e:
logger.error(
f"[AliDnsDDNS] 记录更新失败 | {fqdn} | {rec['type']} | 原因: {e}"
)
if ipv4:
self._last_ipv4 = ipv4
if ipv6:
self._last_ipv6 = ipv6
if not updated:
return
self.__save_history(updated)
if self._notify:
self.__send_notify(updated)
def __save_history(self, new_items: List[dict]):
history: List[dict] = self.get_data("history") or []
history = (new_items + history)[:_MAX_HISTORY]
self.save_data("history", history)
def __send_notify(self, updated: List[dict]):
blocks = []
for item in updated:
type_label = "IPv4" if item["type"] == "A" else "IPv6"
blocks.append(f"{item['fqdn']}{type_label}\n{item['ip']}")
text = "以下记录已同步新 IP\n\n" + "\n\n".join(blocks) + "\n\n查看详情"
self.post_message(
mtype=NotificationType.Plugin,
title="🌐 阿里云 DDNS 已更新",
text=text,
)
# ──────────────────────────────────────────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────────────────────────────────────────
def _fqdn(rr: str, domain: str) -> str:
return domain if rr == "@" else f"{rr}.{domain}"
def _parse_records(raw: str) -> List[Dict[str, str]]:
"""
格式:顶级域名 主机记录 [类型]
示例:
example.com @ A
example.com * A
example.com home AAAA
空行和 # 注释行会被跳过。
"""
result = []
for line in raw.splitlines():
line = line.split("#")[0].strip()
if not line:
continue
parts = line.split()
if len(parts) < 2:
logger.warning(f"[AliDnsDDNS] 忽略无效配置行: {line!r}")
continue
domain = parts[0]
rr = parts[1]
rec_type = parts[2].upper() if len(parts) >= 3 else "A"
if rec_type not in ("A", "AAAA"):
logger.warning(
f"[AliDnsDDNS] 不支持的记录类型 {rec_type!r},已跳过: {line!r}"
)
continue
result.append({"domain": domain, "rr": rr, "type": rec_type})
return result
def _get_public_ip(v6: bool = False) -> Optional[str]:
"""从多个公共端点轮询获取公网 IPv4 或 IPv6 地址,首个成功即返回。"""
sources_v4 = [
"https://api4.ipify.org",
"https://ipv4.icanhazip.com",
"https://myexternalip.com/raw",
"https://ipecho.net/plain",
]
sources_v6 = [
"https://api6.ipify.org",
"https://ipv6.icanhazip.com",
"https://6.ident.me",
]
sources = sources_v6 if v6 else sources_v4
validate = _is_valid_ipv6 if v6 else _is_valid_ipv4
label = "IPv6" if v6 else "IPv4"
for url in sources:
try:
req = urllib.request.Request(
url, headers={"User-Agent": "MoviePilot-AliDnsDDNS/1.0"}
)
with urllib.request.urlopen(req, timeout=8) as resp:
ip = resp.read(64).decode().strip()
if validate(ip):
logger.debug(f"[AliDnsDDNS] 检测到公网 {label}: {ip}(来源: {url}")
return ip
logger.debug(f"[AliDnsDDNS] {url} 返回无效 {label}: {ip!r}")
except Exception as e:
logger.debug(f"[AliDnsDDNS] {label} 检测源不可用: {url}{e}")
logger.warning(f"[AliDnsDDNS] 所有 {label} 检测源均不可用")
return None
def _is_valid_ipv4(ip: str) -> bool:
try:
socket.inet_pton(socket.AF_INET, ip)
return True
except (OSError, socket.error):
return False
def _is_valid_ipv6(ip: str) -> bool:
try:
socket.inet_pton(socket.AF_INET6, ip)
return True
except (OSError, socket.error):
return False
# ──────────────────────────────────────────────────────────────────────────────
# 阿里云 DNS API 客户端(纯标准库)
# ──────────────────────────────────────────────────────────────────────────────
_ALIDNS_ENDPOINT = "https://alidns.aliyuncs.com/"
class _AliDnsClient:
def __init__(self, key_id: str, key_secret: str):
self._key_id = key_id
self._key_secret = key_secret
# ── 签名 ─────────────────────────────────────
@staticmethod
def _percent_encode(s: str) -> str:
e = urllib.parse.quote(s, safe="")
return e.replace("+", "%20").replace("*", "%2A").replace("%7E", "~")
def _sign(self, params: Dict[str, str]) -> str:
canonical = "&".join(
f"{self._percent_encode(k)}={self._percent_encode(params[k])}"
for k in sorted(params)
)
string_to_sign = "GET&%2F&" + self._percent_encode(canonical)
mac = hmac.new(
(self._key_secret + "&").encode(),
string_to_sign.encode(),
hashlib.sha1,
)
return b64encode(mac.digest()).decode()
def _base_params(self, action: str) -> Dict[str, str]:
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=16))
return {
"Action": action,
"Version": "2015-01-09",
"Format": "JSON",
"AccessKeyId": self._key_id,
"SignatureMethod": "HMAC-SHA1",
"SignatureVersion": "1.0",
"SignatureNonce": nonce,
"Timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
def _request(self, params: Dict[str, str]) -> dict:
import json as _json
params["Signature"] = self._sign(params)
url = _ALIDNS_ENDPOINT + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url, headers={"User-Agent": "MoviePilot-AliDnsDDNS/1.0"}
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
body = _json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
# 读取响应体以获取阿里云返回的详细错误信息
try:
err_body = _json.loads(e.read().decode())
code = err_body.get("Code", str(e.code))
msg = err_body.get("Message", e.reason)
except Exception:
code, msg = str(e.code), e.reason
raise RuntimeError(f"HTTP {e.code}{code}: {msg}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"网络请求失败: {e.reason}") from e
if body.get("Code"):
raise RuntimeError(f"{body['Code']}: {body.get('Message', '')}")
return body
# ── CRUD ─────────────────────────────────────
def _list_records(self, domain: str, rr: str, rec_type: str) -> List[dict]:
p = self._base_params("DescribeDomainRecords")
p.update({"DomainName": domain, "RRKeyWord": rr,
"TypeKeyWord": rec_type, "PageSize": "20"})
return self._request(p).get("DomainRecords", {}).get("Record", [])
def _add_record(self, domain: str, rr: str, rec_type: str, value: str):
p = self._base_params("AddDomainRecord")
p.update({"DomainName": domain, "RR": rr,
"Type": rec_type, "Value": value, "TTL": "600"})
self._request(p)
logger.info(f"[AliDnsDDNS] 新建 DNS 记录 | {_fqdn(rr, domain)} | {rec_type} | {value}")
def _update_record(self, record_id: str, rr: str, rec_type: str,
value: str, domain: str = ""):
p = self._base_params("UpdateDomainRecord")
p.update({"RecordId": record_id, "RR": rr,
"Type": rec_type, "Value": value, "TTL": "600"})
self._request(p)
def upsert(self, domain: str, rr: str, rec_type: str, new_ip: str) -> bool:
"""
创建或更新 DNS 记录,更新所有匹配的记录。
返回 True 表示发生了变更False 表示所有记录均已是最新值。
"""
records = self._list_records(domain, rr, rec_type)
# API RRKeyWord 是模糊匹配,需精确过滤
matched = [r for r in records if r.get("RR") == rr and r.get("Type") == rec_type]
if not matched:
self._add_record(domain, rr, rec_type, new_ip)
return True
changed = False
for rec in matched:
if rec.get("Value") == new_ip:
continue # 该条已是最新,跳过
self._update_record(rec["RecordId"], rr, rec_type, new_ip, domain)
changed = True
return changed