Files
vps-manager/app/adapters/xinnet.py
T
gouki 103f48e38b feat(adapters): 接入 CloudCone/Zeabur/Dynadot/新网/Agnes 五个平台
线上平台列表里 cloudcone、zeabur、xinwang、dynadot、agnes 的 sdk_type 仍为空,
平台卡片只能显示「未对接」,用户无法同步。按各自真实 API 能力补齐:

- CloudCone:实例清单 + 开机/关机/重启;无公开账单接口,不声明余额与续费
- Zeabur:服务器清单 + 重启;毫核/MB 归一化为核/GB,未采集时不伪造规格
- Dynadot:域名清单(含到期日)+ 账户余额;set_dns2 位置参数无法安全 CRUD
- 新网:代理 API 按域名逐个查到期日;无列表接口,需在账号里填 domains 清单
- Agnes:/models 校验 Key;无余额接口,如实标记不支持

配套改动:
- _align_preset_providers 为库中为空的 sdk_type 回填种子值(启动即生效),
  避免新适配器上线后线上仍停留在「未对接」
- 适配器可声明 plain_config,代理号/域名等非密钥字段前端按明文输入
- PRESET_PROVIDERS 五条记录填上对应 sdk_type

测试:445 passed(新增 tests/test_platform_adapters.py),vitest 62 passed。
2026-10-26 09:16:45 +00:00

123 lines
4.7 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.
"""新网(Xinnet)代理 API 适配器
API 文档:新网「域名代理 API」(api.xinnet.com/domain/api.gb)
所需配置:{"client": "代理号", "password": "API 密码", "domains": "a.com,b.com"}
限制(必须如实告知,不能假装能一键同步):
- 只有新网代理账号能调用,且必须在代理后台登记调用来源 IP,API 每年需年审;
- 接口没有「列出全部域名」的方法,必须由用户提供要核对的域名清单;
- 新网零售客户区登录带腾讯验证码,无法用面板抓取替代。
覆盖:按配置的域名逐个查询到期日(GetProductKey / keyname=ExpireDate)。
响应不是 JSON,而是 URL 编码的 key=value 串,成功时形如
``name=example.com&key=2028-03-01``,失败时形如 ``ret=0&err=client-noexist``。
"""
import hashlib
import re
from urllib.parse import parse_qsl
import httpx
from app.adapters.base import BaseAdapter, NormalizedDomain, parse_iso_date
from app.adapters.registry import register
_DOMAIN_SPLIT_RE = re.compile(r"[,,;;\s]+")
@register("xinnet-api")
class XinnetAdapter(BaseAdapter):
required_config = ["client", "password", "domains"]
#: 代理号和域名清单不是密钥,前端按明文输入
plain_config = ["client", "domains"]
BASE = "https://api.xinnet.com/domain/api.gb"
def _sign(self, method: str, parts: list) -> str:
raw = method + str(self.config.get("client") or "") + str(
self.config.get("password") or ""
) + "".join(parts)
return hashlib.md5(raw.encode("utf-8")).hexdigest()
def _post(self, method: str, params: dict, sign_parts: list) -> dict:
payload = {
"client": self.config.get("client") or "",
**params,
"checksum": self._sign(method, sign_parts),
}
with httpx.Client(timeout=30) as client:
resp = client.post(
self.BASE,
params={"method": method, "charset": "utf-8"},
data=payload,
)
resp.raise_for_status()
data = dict(parse_qsl(resp.text.strip(), keep_blank_values=True))
ret = data.get("ret")
if ret is not None and str(ret) != "100":
reason = data.get("err") or data.get("info") or "未知错误"
raise RuntimeError(f"新网 {method} 失败(ret={ret}):{reason}")
return data
@staticmethod
def _enc(domain: str) -> str:
return "G" if any(ord(ch) > 127 for ch in domain) else "E"
def _domains(self) -> list:
raw = str(self.config.get("domains") or "")
seen = set()
result = []
for item in _DOMAIN_SPLIT_RE.split(raw):
domain = item.strip().lower()
if domain and domain not in seen:
seen.add(domain)
result.append(domain)
return result
def _expiry(self, domain: str) -> str | None:
enc = self._enc(domain)
data = self._post(
"GetProductKey",
{"name": domain, "enc": enc, "keyname": "ExpireDate"},
[domain, enc],
)
return data.get("key") or data.get("ExpireDate")
def test_connection(self) -> dict:
domains = self._domains()
if not domains:
return {"ok": False, "message": "请填写要核对的新网域名(逗号分隔)"}
try:
expiry = self._expiry(domains[0])
if not expiry:
return {"ok": False, "message": f"接口已连通,但未返回 {domains[0]} 的到期日"}
return {
"ok": True,
"message": f"连接成功,{domains[0]} 到期 {expiry}(共 {len(domains)} 个域名)",
}
except httpx.HTTPStatusError as e:
return {
"ok": False,
"message": f"HTTP {e.response.status_code}:请确认代理号、API 密码与来源 IP 白名单",
}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": str(e)}
def list_domains(self) -> list:
result = []
for domain in self._domains():
expiry_text = self._expiry(domain)
if not expiry_text:
raise RuntimeError(f"新网未返回 {domain} 的到期日,请确认该域名属于此代理账号")
result.append(
NormalizedDomain(
external_id=domain,
domain_name=domain,
registrar="xinnet",
expiry_date=parse_iso_date(expiry_text),
status="active",
raw={"domain": domain, "expiry": expiry_text},
)
)
return result