feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
This commit is contained in:
+251
-12
@@ -3,8 +3,14 @@
|
||||
API 文档:https://help.aliyun.com/document_detail/25484.html
|
||||
所需配置:{"access_key_id": "...", "access_key_secret": "...", "region": "cn-hangzhou"}
|
||||
国际版(alibabacloud)只需将 region 设为海外区域(如 ap-southeast-1),端点自动按区域构造。
|
||||
说明:此处实现 RPC V1 签名(HMAC-SHA1),可直接调用 ECS OpenAPI;
|
||||
生产环境也可替换为官方 alibabacloud-ecs SDK,接口契约保持不变。
|
||||
|
||||
RPC V1 签名对所有产品线一致,只有 endpoint 与 Version 不同,故 _call 接受这两个参数:
|
||||
- ECS ecs.{region}.aliyuncs.com 2014-05-26 实例清单与启停/重启/续费
|
||||
- Domain domain.aliyuncs.com 2018-01-29 注册域名清单(含到期日)
|
||||
- BSS business.aliyuncs.com 2017-12-14 账户余额
|
||||
- Alidns alidns.aliyuncs.com 2015-01-09 解析记录增删改查
|
||||
|
||||
DNS 的 zone_id 即域名本身(阿里云不用 zone uuid)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -16,19 +22,36 @@ from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import BaseAdapter, NormalizedVPS
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
ECS_VERSION = "2014-05-26"
|
||||
DOMAIN_VERSION = "2018-01-29"
|
||||
BSS_VERSION = "2017-12-14"
|
||||
ALIDNS_VERSION = "2015-01-09"
|
||||
|
||||
#: ECS InstanceChargeType -> 台账 charge_type
|
||||
_CHARGE_TYPE_MAP = {"PrePaid": "prepaid", "PostPaid": "postpaid"}
|
||||
|
||||
#: ECS Status -> 台账状态
|
||||
_STATUS_MAP = {"Running": "active", "Stopped": "stopped", "Stopping": "stopped", "Starting": "active"}
|
||||
|
||||
|
||||
@register("aliyun-sdk", "alibabacloud-sdk")
|
||||
class AliyunAdapter(BaseAdapter):
|
||||
required_config = ["access_key_id", "access_key_secret"]
|
||||
API_VERSION = "2014-05-26"
|
||||
|
||||
def _region(self) -> str:
|
||||
return self.config.get("region") or "cn-hangzhou"
|
||||
|
||||
def _endpoint(self) -> str:
|
||||
def _ecs_endpoint(self) -> str:
|
||||
return f"https://ecs.{self._region()}.aliyuncs.com"
|
||||
|
||||
@staticmethod
|
||||
@@ -45,23 +68,33 @@ class AliyunAdapter(BaseAdapter):
|
||||
digest = hmac.new(key, string_to_sign.encode(), hashlib.sha1).digest()
|
||||
return base64.b64encode(digest).decode()
|
||||
|
||||
def _call(self, action: str, biz_params: dict = None) -> dict:
|
||||
def _call(
|
||||
self,
|
||||
action: str,
|
||||
biz_params: dict = None,
|
||||
endpoint: str = None,
|
||||
version: str = ECS_VERSION,
|
||||
with_region: bool = True,
|
||||
) -> dict:
|
||||
"""调用任一阿里云 RPC 产品;endpoint 缺省为 ECS 区域端点"""
|
||||
params = {
|
||||
"Format": "JSON",
|
||||
"Version": self.API_VERSION,
|
||||
"Version": version,
|
||||
"AccessKeyId": self.config.get("access_key_id", ""),
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"SignatureVersion": "1.0",
|
||||
"SignatureNonce": str(uuid.uuid4()),
|
||||
"Action": action,
|
||||
"RegionId": self._region(),
|
||||
}
|
||||
# BSS / Domain / Alidns 是中心化服务,传 RegionId 会被拒或忽略
|
||||
if with_region:
|
||||
params["RegionId"] = self._region()
|
||||
if biz_params:
|
||||
params.update(biz_params)
|
||||
params.update({k: v for k, v in biz_params.items() if v is not None})
|
||||
params["Signature"] = self._sign(params)
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(self._endpoint(), params=params)
|
||||
resp = client.get(endpoint or self._ecs_endpoint(), params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("Code"):
|
||||
@@ -78,6 +111,8 @@ class AliyunAdapter(BaseAdapter):
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def list_vps(self) -> list:
|
||||
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
|
||||
result = []
|
||||
@@ -89,7 +124,7 @@ class AliyunAdapter(BaseAdapter):
|
||||
public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", [])
|
||||
eip = inst.get("EipAddress", {}).get("IpAddress")
|
||||
mem_mb = inst.get("Memory") or 0
|
||||
status_map = {"Running": "active", "Stopped": "stopped"}
|
||||
charge = inst.get("InstanceChargeType")
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("InstanceId"),
|
||||
@@ -99,8 +134,11 @@ class AliyunAdapter(BaseAdapter):
|
||||
os=inst.get("OSName"),
|
||||
cpu_cores=inst.get("Cpu"),
|
||||
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
|
||||
status=status_map.get(inst.get("Status"), inst.get("Status") or "unknown"),
|
||||
status=_STATUS_MAP.get(inst.get("Status"), inst.get("Status") or "unknown"),
|
||||
currency="CNY",
|
||||
# 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效
|
||||
expiry_date=parse_iso_date(inst.get("ExpiredTime")),
|
||||
charge_type=_CHARGE_TYPE_MAP.get(charge, charge),
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
@@ -109,3 +147,204 @@ class AliyunAdapter(BaseAdapter):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
def list_domains(self) -> list:
|
||||
"""注册域名清单(domain.aliyuncs.com QueryDomainList)
|
||||
|
||||
与 DNS 托管是两回事:这里取的是「在阿里云注册的域名 + 到期日」,
|
||||
正是续费提醒需要的数据。Token 无域名权限时抛错由上层捕获,不静默返回空。
|
||||
"""
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._call(
|
||||
"QueryDomainList",
|
||||
{"PageNum": str(page), "PageSize": "100"},
|
||||
endpoint="https://domain.aliyuncs.com",
|
||||
version=DOMAIN_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
domains = data.get("Data", {}).get("Domain", [])
|
||||
for d in domains:
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=d.get("DomainName"),
|
||||
domain_name=d.get("DomainName"),
|
||||
registrar="aliyun",
|
||||
expiry_date=parse_iso_date(d.get("ExpirationDate") or d.get("ExpirationDateLong")),
|
||||
status="active" if not d.get("Premium") else "active",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
total = int(data.get("TotalItemNum") or data.get("TotalCount") or 0)
|
||||
if not domains or page * 100 >= total:
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
"""账户余额(business.aliyuncs.com QueryAccountBalance)"""
|
||||
data = self._call(
|
||||
"QueryAccountBalance",
|
||||
endpoint="https://business.aliyuncs.com",
|
||||
version=BSS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
d = data.get("Data", {})
|
||||
# AvailableAmount 返回字符串(如 "123.45"),转 float 失败时保持 None
|
||||
balance = self._to_float(d.get("AvailableAmount"))
|
||||
return AccountInfo(
|
||||
balance=balance,
|
||||
currency=d.get("Currency") or "CNY",
|
||||
pending_charges=self._to_float(d.get("MybankCreditAmount")),
|
||||
raw=d,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _action(self, action: str, params: dict, label: str) -> dict:
|
||||
try:
|
||||
self._call(action, params)
|
||||
return {"ok": True, "message": f"{label}指令已下发"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"{label}失败:{e}"}
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
return self._action("StartInstance", {"InstanceId": external_id}, "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
# ForceStop=false 走正常关机;强制断电有丢数据风险,不默认开启
|
||||
return self._action(
|
||||
"StopInstance", {"InstanceId": external_id, "ForceStop": "false"}, "关机"
|
||||
)
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._action(
|
||||
"RebootInstance", {"InstanceId": external_id, "ForceStop": "false"}, "重启"
|
||||
)
|
||||
|
||||
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
|
||||
# RenewInstance 仅对预付费实例有效,后付费会返回 IncorrectInstanceStatus
|
||||
return self._action(
|
||||
"RenewInstance",
|
||||
{"InstanceId": external_id, "Period": str(period_months)},
|
||||
f"续费 {period_months} 个月",
|
||||
)
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._call(
|
||||
"DescribeDomainRecords",
|
||||
{"DomainName": zone_id, "PageNumber": str(page), "PageSize": "100"},
|
||||
endpoint="https://alidns.aliyuncs.com",
|
||||
version=ALIDNS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
records = data.get("DomainRecords", {}).get("Record", [])
|
||||
for r in records:
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("RecordId")),
|
||||
zone_id=zone_id,
|
||||
name=self._fqdn(r.get("RR"), zone_id),
|
||||
type=r.get("Type"),
|
||||
content=r.get("Value"),
|
||||
ttl=int(r.get("TTL")) if r.get("TTL") else None,
|
||||
priority=int(r.get("Priority")) if r.get("Priority") else None,
|
||||
comment=r.get("Remark"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
total = int(data.get("TotalCount") or 0)
|
||||
if not records or page * 100 >= total:
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(rr: str, zone_id: str) -> str:
|
||||
"""阿里云存的是主机记录(RR,如 www / @),拼成完整域名供台账展示"""
|
||||
if not rr or rr == "@":
|
||||
return zone_id
|
||||
return f"{rr}.{zone_id}"
|
||||
|
||||
@staticmethod
|
||||
def _rr(name: str, zone_id: str) -> str:
|
||||
"""反向:把完整域名拆回主机记录"""
|
||||
if name == zone_id:
|
||||
return "@"
|
||||
suffix = f".{zone_id}"
|
||||
return name[: -len(suffix)] if name.endswith(suffix) else name
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
params = {
|
||||
"DomainName": zone_id,
|
||||
"RR": self._rr(record.get("name", ""), zone_id),
|
||||
"Type": record.get("type", "A"),
|
||||
"Value": record.get("content", ""),
|
||||
"TTL": str(record.get("ttl") or 600),
|
||||
"Priority": str(record["priority"]) if record.get("priority") else None,
|
||||
}
|
||||
try:
|
||||
data = self._call(
|
||||
"AddDomainRecord",
|
||||
params,
|
||||
endpoint="https://alidns.aliyuncs.com",
|
||||
version=ALIDNS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("RecordId", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
params = {
|
||||
"RecordId": record_id,
|
||||
"RR": self._rr(record.get("name", ""), zone_id),
|
||||
"Type": record.get("type", "A"),
|
||||
"Value": record.get("content", ""),
|
||||
"TTL": str(record.get("ttl") or 600),
|
||||
"Priority": str(record["priority"]) if record.get("priority") else None,
|
||||
}
|
||||
try:
|
||||
data = self._call(
|
||||
"UpdateDomainRecord",
|
||||
params,
|
||||
endpoint="https://alidns.aliyuncs.com",
|
||||
version=ALIDNS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
return {"ok": True, "message": "记录已更新", "raw": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
data = self._call(
|
||||
"DeleteDomainRecord",
|
||||
{"RecordId": record_id},
|
||||
endpoint="https://alidns.aliyuncs.com",
|
||||
version=ALIDNS_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
return {"ok": True, "message": "记录已删除", "raw": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""AWS 适配器(SigV4 签名,EC2 查询协议 + Route53 JSON 协议)
|
||||
|
||||
所需配置:{"access_key_id": "...", "secret_access_key": "...", "region": "us-east-1"}
|
||||
|
||||
不引入 boto3:项目其余适配器统一走 httpx + 手写签名(阿里云 RPC V1、腾讯云 TC3),
|
||||
boto3 会带来数十 MB 依赖与独立的凭证/重试体系,与现有风格割裂。
|
||||
|
||||
协议差异(SigV4 相同,封装不同):
|
||||
- EC2 ec2.{region}.amazonaws.com 查询协议,POST 表单编码 body
|
||||
- Route53 route53.amazonaws.com JSON 1.1,X-Amz-Target 头,全局无区域
|
||||
- Route53Domains route53domains.{region}... JSON 1.1,域名注册清单(含到期日)
|
||||
|
||||
刻意不实现 get_account:AWS 没有「账户余额」这一概念(预付的是信用额/账单),
|
||||
硬凑一个数字会比如实报「不支持」更误导,参见 ai.py 对 OpenAI 的同类处理。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import (
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
EC2_VERSION = "2016-11-15"
|
||||
ROUTE53DOMAINS_TARGET_PREFIX = "Route53Domains_v20140515"
|
||||
|
||||
#: EC2 实例状态 -> 台账状态;terminated/shutting-down 不入库,避免台账堆满已销毁实例
|
||||
_STATE_MAP = {
|
||||
"running": "active",
|
||||
"stopped": "stopped",
|
||||
"stopping": "stopped",
|
||||
"pending": "active",
|
||||
}
|
||||
|
||||
_ALGORITHM = "AWS4-HMAC-SHA256"
|
||||
|
||||
|
||||
def _sign(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _signing_key(secret: str, date_stamp: str, region: str, service: str) -> bytes:
|
||||
k_date = _sign(("AWS4" + secret).encode("utf-8"), date_stamp)
|
||||
k_region = _sign(k_date, region)
|
||||
k_service = _sign(k_region, service)
|
||||
return _sign(k_service, "aws4_request")
|
||||
|
||||
|
||||
@register("aws-api")
|
||||
class AWSAdapter(BaseAdapter):
|
||||
required_config = ["access_key_id", "secret_access_key"]
|
||||
|
||||
def _region(self) -> str:
|
||||
return self.config.get("region") or "us-east-1"
|
||||
|
||||
def _credentials(self) -> tuple[str, str]:
|
||||
return (
|
||||
self.config.get("access_key_id", ""),
|
||||
self.config.get("secret_access_key", ""),
|
||||
)
|
||||
|
||||
def _signed_request(
|
||||
self,
|
||||
service: str,
|
||||
host: str,
|
||||
region: str,
|
||||
method: str,
|
||||
path: str,
|
||||
body: str = "",
|
||||
content_type: str = "application/x-www-form-urlencoded; charset=utf-8",
|
||||
extra_headers: dict = None,
|
||||
) -> dict:
|
||||
"""构造并发送一个 SigV4 签名请求,返回解析后的 JSON/XML-less dict"""
|
||||
access_key, secret_key = self._credentials()
|
||||
now = datetime.now(timezone.utc)
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = now.strftime("%Y%m%d")
|
||||
|
||||
headers = {"host": host, "x-amz-date": amz_date, "content-type": content_type}
|
||||
if extra_headers:
|
||||
headers.update({k.lower(): v for k, v in extra_headers.items()})
|
||||
|
||||
signed_headers = ";".join(sorted(headers))
|
||||
canonical_headers = "".join(f"{k}:{headers[k].strip()}\n" for k in sorted(headers))
|
||||
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||
|
||||
# 查询协议的参数在 body 里,CanonicalQueryString 为空;path 恒为 "/"
|
||||
canonical_request = (
|
||||
f"{method}\n{path}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
||||
)
|
||||
credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
|
||||
string_to_sign = (
|
||||
f"{_ALGORITHM}\n{amz_date}\n{credential_scope}\n"
|
||||
f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
||||
)
|
||||
signature = hmac.new(
|
||||
_signing_key(secret_key, date_stamp, region, service),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
headers["Authorization"] = (
|
||||
f"{_ALGORITHM} Credential={access_key}/{credential_scope}, "
|
||||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
# httpx 会自己算 Content-Length,host 头也由它设;这里传签名时用的小写 host 值必须一致
|
||||
send_headers = {k: v for k, v in headers.items() if k != "host"}
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.request(
|
||||
method, f"https://{host}{path}", content=body.encode("utf-8"), headers=send_headers
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
# ---------------- EC2(查询协议) ----------------
|
||||
|
||||
def _ec2_call(self, params: dict) -> dict:
|
||||
body = "&".join(f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in sorted(params.items()))
|
||||
return self._signed_request(
|
||||
service="ec2",
|
||||
host=f"ec2.{self._region()}.amazonaws.com",
|
||||
region=self._region(),
|
||||
method="POST",
|
||||
path="/",
|
||||
body=body,
|
||||
)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
data = self._ec2_call({"Action": "DescribeRegions", "Version": EC2_VERSION})
|
||||
count = len((data.get("regionInfo") or {}).get("item") or [])
|
||||
return {"ok": True, "message": f"连接成功,可用区域 {count} 个"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:凭证无效或权限不足"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def list_vps(self) -> list:
|
||||
result = []
|
||||
token = None
|
||||
while True:
|
||||
params = {"Action": "DescribeInstances", "Version": EC2_VERSION}
|
||||
if token:
|
||||
params["NextToken"] = token
|
||||
data = self._ec2_call(params)
|
||||
# EC2 查询协议转 JSON 后是驼峰包装:reservationSet.item[].instancesSet.item[]
|
||||
reservations = (data.get("reservationSet") or {}).get("item") or []
|
||||
if isinstance(reservations, dict):
|
||||
reservations = [reservations]
|
||||
for res in reservations:
|
||||
instances = (res.get("instancesSet") or {}).get("item") or []
|
||||
if isinstance(instances, dict):
|
||||
instances = [instances]
|
||||
for inst in instances:
|
||||
state = ((inst.get("instanceState") or {}).get("name") or "").lower()
|
||||
if state not in _STATE_MAP:
|
||||
continue # terminated / shutting-down 不入台账
|
||||
tags = {
|
||||
t.get("key"): t.get("value")
|
||||
for t in ((inst.get("tagSet") or {}).get("item") or [])
|
||||
}
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("instanceId"),
|
||||
name=tags.get("Name") or inst.get("instanceId"),
|
||||
ip_address=inst.get("ipAddress") or inst.get("privateIpAddress"),
|
||||
region=(inst.get("placement") or {}).get("availabilityZone"),
|
||||
os=None, # EC2 列表只给 imageId,解析平台名需额外 DescribeImages
|
||||
cpu_cores=None,
|
||||
memory_gb=None,
|
||||
status=_STATE_MAP[state],
|
||||
currency="USD",
|
||||
# AWS 以按需/预留为主,实例列表里没有到期日;预留实例到期在
|
||||
# DescribeReservedInstances 里,与单机不是一对一,故不猜
|
||||
expiry_date=None,
|
||||
charge_type=(inst.get("instanceLifecycle") or "on-demand"),
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
token = data.get("nextToken")
|
||||
if not token:
|
||||
break
|
||||
return result
|
||||
|
||||
def _ec2_action(self, action: str, external_id: str, label: str) -> dict:
|
||||
try:
|
||||
self._ec2_call(
|
||||
{"Action": action, "Version": EC2_VERSION, "InstanceId.1": external_id}
|
||||
)
|
||||
return {"ok": True, "message": f"{label}指令已下发"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"{label}失败:{e}"}
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
return self._ec2_action("StartInstances", external_id, "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
return self._ec2_action("StopInstances", external_id, "关机")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._ec2_action("RebootInstances", external_id, "重启")
|
||||
|
||||
# ---------------- Route53 注册域名(含到期日) ----------------
|
||||
|
||||
def _r53domains_call(self, action: str, payload: dict) -> dict:
|
||||
return self._signed_request(
|
||||
service="route53domains",
|
||||
host=f"route53domains.{self._region()}.amazonaws.com",
|
||||
region=self._region(),
|
||||
method="POST",
|
||||
path="/",
|
||||
body=json.dumps(payload),
|
||||
content_type="application/x-amz-json-1.1",
|
||||
extra_headers={"X-Amz-Target": f"{ROUTE53DOMAINS_TARGET_PREFIX}.{action}"},
|
||||
)
|
||||
|
||||
def list_domains(self) -> list:
|
||||
"""Route53 注册域名清单(含到期日)
|
||||
|
||||
只覆盖在 Route53 注册的域名;仅用 Route53 托管 DNS、域名在别处注册的场景
|
||||
请走 list_dns_records(hosted zone)而不是这里。
|
||||
"""
|
||||
result = []
|
||||
marker = None
|
||||
while True:
|
||||
payload = {"MaxItems": 100}
|
||||
if marker:
|
||||
payload["Marker"] = marker
|
||||
data = self._r53domains_call("ListDomains", payload)
|
||||
for d in data.get("Domains", []):
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=d.get("DomainName"),
|
||||
domain_name=d.get("DomainName"),
|
||||
registrar="aws-route53",
|
||||
# Expiry 是 epoch 秒
|
||||
expiry_date=self._epoch_to_date(d.get("Expiry")),
|
||||
status="active",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
marker = data.get("NextPageMarker")
|
||||
if not marker:
|
||||
break
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _epoch_to_date(value):
|
||||
try:
|
||||
return parse_iso_date(datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat())
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
# ---------------- Route53 DNS 记录 ----------------
|
||||
|
||||
@staticmethod
|
||||
def _hosted_zone_id(zone_id: str) -> str:
|
||||
"""Route53 的 zone id 形如 /hostedzone/Z123,API 只要 Z123"""
|
||||
return zone_id.rsplit("/", 1)[-1]
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
zid = self._hosted_zone_id(zone_id)
|
||||
data = self._signed_request(
|
||||
service="route53",
|
||||
host="route53.amazonaws.com",
|
||||
region="us-east-1",
|
||||
method="GET",
|
||||
path=f"/2013-04-01/hostedzone/{zid}/rrset",
|
||||
body="",
|
||||
content_type="application/json",
|
||||
)
|
||||
result = []
|
||||
for r in data.get("ResourceRecordSets", []):
|
||||
values = [rr.get("Value") for rr in (r.get("ResourceRecords") or [])]
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=f"{r.get('Name')}|{r.get('Type')}",
|
||||
zone_id=zone_id,
|
||||
name=(r.get("Name") or "").rstrip("."),
|
||||
type=r.get("Type"),
|
||||
content=", ".join(v for v in values if v),
|
||||
ttl=r.get("TTL"),
|
||||
priority=None,
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def _change(self, zone_id: str, action: str, record: dict) -> dict:
|
||||
zid = self._hosted_zone_id(zone_id)
|
||||
# Route53 的记录值必须是数组,单值也要包一层
|
||||
values = [v.strip() for v in str(record.get("content", "")).split(",") if v.strip()]
|
||||
change = {
|
||||
"Action": action,
|
||||
"ResourceRecordSet": {
|
||||
"Name": record.get("name", ""),
|
||||
"Type": record.get("type", "A"),
|
||||
"TTL": record.get("ttl") or 300,
|
||||
"ResourceRecords": [{"Value": v} for v in values],
|
||||
},
|
||||
}
|
||||
body = json.dumps({"Changes": [change]})
|
||||
try:
|
||||
self._signed_request(
|
||||
service="route53",
|
||||
host="route53.amazonaws.com",
|
||||
region="us-east-1",
|
||||
method="POST",
|
||||
path=f"/2013-04-01/hostedzone/{zid}/change",
|
||||
body=body,
|
||||
# Route53 的 /2013-04-01/* 是 REST-JSON(按路径路由,无 X-Amz-Target),
|
||||
# 用 x-amz-json-1.1 会被当成 JSON 协议服务而报 MissingAuthenticationToken
|
||||
content_type="application/json",
|
||||
)
|
||||
return {"ok": True}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
res = self._change(zone_id, "CREATE", record)
|
||||
if not res["ok"]:
|
||||
return {"ok": False, "message": f"创建失败:{res['message']}"}
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建(Route53 变更为异步,生效需数秒)",
|
||||
"record_id": f"{record.get('name')}|{record.get('type')}",
|
||||
}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
res = self._change(zone_id, "UPSERT", record)
|
||||
if not res["ok"]:
|
||||
return {"ok": False, "message": f"更新失败:{res['message']}"}
|
||||
return {"ok": True, "message": "记录已更新(Route53 变更为异步,生效需数秒)"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
# record_id 形如 "name|TYPE",Route53 删除必须带上完整的记录集
|
||||
if "|" not in record_id:
|
||||
return {"ok": False, "message": "删除失败:record_id 需为 name|TYPE 形式"}
|
||||
name, rtype = record_id.rsplit("|", 1)
|
||||
try:
|
||||
existing = next(
|
||||
(r for r in self.list_dns_records(zone_id) if r.record_id == record_id), None
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:无法读取现有记录 {e}"}
|
||||
if not existing:
|
||||
return {"ok": False, "message": "删除失败:记录不存在"}
|
||||
res = self._change(
|
||||
zone_id,
|
||||
"DELETE",
|
||||
{"name": name, "type": rtype, "content": existing.content, "ttl": existing.ttl},
|
||||
)
|
||||
if not res["ok"]:
|
||||
return {"ok": False, "message": f"删除失败:{res['message']}"}
|
||||
return {"ok": True, "message": "记录已删除(Route53 变更为异步)"}
|
||||
+159
-6
@@ -5,11 +5,14 @@
|
||||
供同步服务写入数据库。这样上层逻辑无需关心各平台 API 差异。
|
||||
"""
|
||||
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def parse_iso_date(value: Any) -> Optional[date]:
|
||||
"""把到期日归一化为 date 对象(接受 date / datetime / ISO 字符串,解析失败返回 None)
|
||||
@@ -33,6 +36,78 @@ def parse_iso_date(value: Any) -> Optional[date]:
|
||||
return None
|
||||
|
||||
|
||||
# 状态码 -> 排查提示。厂商 body 有时只给一个 code 不给原因(AWS 的 403、
|
||||
# Vultr 的 401 就常这样),这时至少告诉用户往哪个方向查。
|
||||
_STATUS_HINTS = {
|
||||
401: "凭证无效或已过期",
|
||||
403: "凭证权限不足",
|
||||
404: "资源不存在(实例 ID 或域名可能已变更)",
|
||||
429: "触发平台限流,请稍后重试",
|
||||
}
|
||||
|
||||
# 厂商放错误原因的键名(各家不统一,按此顺序找第一个可读字符串)
|
||||
_ERROR_KEYS = ("message", "msg", "reason", "error", "errormsg", "detail", "description")
|
||||
|
||||
|
||||
def _scan_error_text(node: Any, depth: int = 0) -> Optional[str]:
|
||||
"""在嵌套 JSON 里递归找第一个像错误原因的字符串"""
|
||||
if depth > 4:
|
||||
return None
|
||||
if isinstance(node, dict):
|
||||
for key in _ERROR_KEYS:
|
||||
for k, v in node.items():
|
||||
if k.lower() == key and isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
for v in node.values():
|
||||
found = _scan_error_text(v, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
elif isinstance(node, list):
|
||||
for v in node:
|
||||
found = _scan_error_text(v, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def http_error_message(e: httpx.HTTPStatusError) -> str:
|
||||
"""把云厂商的 HTTP 错误压成一句人话
|
||||
|
||||
直接 str(e) 拿到的是 httpx 的多行文本,末尾还附一段 MDN 链接,
|
||||
显示在弹窗里既难看也没法排查。各厂商把原因放的位置又都不一样
|
||||
(Linode 在 body.errors[0].reason、Cloudflare 在 body.errors[0].message、
|
||||
腾讯云在 Response.Error.Message、AWS 在 XML 里),所以统一在这里提取。
|
||||
"""
|
||||
resp = e.response
|
||||
code = resp.status_code
|
||||
detail = None
|
||||
try:
|
||||
detail = _scan_error_text(resp.json())
|
||||
except Exception: # noqa: BLE001
|
||||
# AWS 等返回 XML,退化成抓 <Message>
|
||||
found = re.search(r"<Message>(.*?)</Message>", resp.text or "", re.S)
|
||||
if found:
|
||||
detail = found.group(1).strip()
|
||||
parts = [f"HTTP {code}"]
|
||||
if detail:
|
||||
parts.append(detail[:200])
|
||||
hint = _STATUS_HINTS.get(code)
|
||||
if hint and (not detail or hint not in detail):
|
||||
parts.append(hint)
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
def cloud_error_text(e: Exception) -> str:
|
||||
"""云端异常 -> 一句可排查的话
|
||||
|
||||
HTTP 错误交给 http_error_message 提取厂商 body 里的原因;
|
||||
其余(超时、DNS 解析失败、连接被重置)本身就短,直接用 str(e)。
|
||||
"""
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
return http_error_message(e)
|
||||
return str(e)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedVPS:
|
||||
"""标准化的 VPS 实例"""
|
||||
@@ -46,8 +121,41 @@ class NormalizedVPS:
|
||||
memory_gb: Optional[float] = None
|
||||
disk_gb: Optional[int] = None
|
||||
status: str = "active"
|
||||
#: 月度费用。只有 Vultr / DO 的列表接口直接返回价格,其余厂商(阿里云、腾讯云、
|
||||
#: AWS、Linode)的 DescribeInstances 一律没有价格字段——要拿到得另调计价 API,
|
||||
#: 而那是挂牌价,不等于用户实付(折扣、备份、IPv4、额外硬盘都会改变金额)。
|
||||
#: 台账里 Asset.cost 的语义是「单次续费费用」,属于用户自己维护的账目值;
|
||||
#: 用一个看似合理的挂牌价去覆盖它,比留空更糟。所以这里刻意留 None,
|
||||
#: 前端把 cost=0 显示为「未填」而非「0 USD」,总览页则报出未填项数。
|
||||
monthly_cost: Optional[float] = None
|
||||
currency: str = "USD"
|
||||
#: 预付费实例的到期日;后付费(按量/按小时)平台恒为 None。
|
||||
#: 缺了它,云端同步进来的 VPS 永远进不了续费提醒——这是台账的核心用途。
|
||||
expiry_date: Optional[date] = None
|
||||
#: 计费方式,用于前端区分「预付费会到期」与「后付费不会到期」
|
||||
charge_type: Optional[str] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = asdict(self)
|
||||
if d.get("expiry_date") is not None:
|
||||
d["expiry_date"] = d["expiry_date"].isoformat()
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedDNSRecord:
|
||||
"""标准化的 DNS 解析记录"""
|
||||
|
||||
record_id: str
|
||||
zone_id: str
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
ttl: Optional[int] = None
|
||||
proxied: Optional[bool] = None
|
||||
priority: Optional[int] = None
|
||||
comment: Optional[str] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -96,13 +204,26 @@ def _overridden(instance: "BaseAdapter", method_name: str) -> bool:
|
||||
return impl is not None and impl is not getattr(BaseAdapter, method_name, None)
|
||||
|
||||
|
||||
#: 只读能力(同步用)
|
||||
READ_CAPABILITIES = ("list_vps", "list_domains", "get_account")
|
||||
#: 实例管理动作(写操作,前端须二次确认)
|
||||
INSTANCE_ACTIONS = ("start_instance", "stop_instance", "reboot_instance", "renew_instance")
|
||||
#: DNS 记录管理动作(写操作)
|
||||
DNS_ACTIONS = ("list_dns_records", "create_dns_record", "update_dns_record", "delete_dns_record")
|
||||
|
||||
|
||||
class BaseAdapter(ABC):
|
||||
"""云厂商适配器抽象基类
|
||||
|
||||
子类需:
|
||||
- 通过 @register("xxx-sdk") 注册到注册表
|
||||
- 设置 required_config(所需凭证字段,供前端表单提示)
|
||||
- 实现 test_connection;按需重写 list_vps / list_domains / get_account
|
||||
- 实现 test_connection;按需重写只读能力与动作方法
|
||||
|
||||
能力分三层,全部通过 capabilities() 如实上报,前端据此决定按钮可用性:
|
||||
1. 只读清单:list_vps / list_domains / get_account
|
||||
2. 实例动作:start / stop / reboot / renew_instance(写操作)
|
||||
3. DNS 动作:list / create / update / delete_dns_record(写操作)
|
||||
"""
|
||||
|
||||
#: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"])
|
||||
@@ -115,6 +236,8 @@ class BaseAdapter(ABC):
|
||||
def test_connection(self) -> dict:
|
||||
"""测试连接 / 凭证有效性,返回 {"ok": bool, "message": str, ...}"""
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def list_vps(self) -> list:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_vps")
|
||||
|
||||
@@ -124,10 +247,40 @@ class BaseAdapter(ABC):
|
||||
def get_account(self) -> AccountInfo:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 get_account")
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
# 统一返回 {"ok": bool, "message": str, "raw": Any},与 test_connection 同构,
|
||||
# 让路由层不必区分「厂商返回成功」与「适配器不支持」。
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 start_instance")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 stop_instance")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 reboot_instance")
|
||||
|
||||
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
|
||||
"""续费预付费实例(后付费平台无此概念,保持未实现即可)"""
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 renew_instance")
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 list_dns_records")
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 create_dns_record")
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 update_dns_record")
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
raise NotImplementedError(f"{type(self).__name__} 未实现 delete_dns_record")
|
||||
|
||||
# ---------------- 能力探测 ----------------
|
||||
|
||||
def capabilities(self) -> dict:
|
||||
"""返回适配器实际支持的能力(是否重写了相应方法)"""
|
||||
return {
|
||||
"list_vps": _overridden(self, "list_vps"),
|
||||
"list_domains": _overridden(self, "list_domains"),
|
||||
"get_account": _overridden(self, "get_account"),
|
||||
}
|
||||
names = READ_CAPABILITIES + INSTANCE_ACTIONS + DNS_ACTIONS
|
||||
return {name: _overridden(self, name) for name in names}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
"""Cloudflare 适配器(REST API v4,Bearer Token)
|
||||
|
||||
API 文档:https://developers.cloudflare.com/api/
|
||||
所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read / Registrar:Read)
|
||||
所需配置:{"api_token": "..."}
|
||||
Token 权限建议:Zone:Read / Zone:Edit(DNS 记录写操作需要)/ Account:Read / Registrar:Read
|
||||
Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。
|
||||
域名到期日通过 Registrar API(/accounts/{id}/registrar/domains)补充获取。
|
||||
DNS 的 zone_id 即 zone uuid(与 list_domains 返回的 external_id 一致)。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import BaseAdapter, NormalizedDomain, parse_iso_date
|
||||
from app.adapters.base import (
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
|
||||
@@ -20,16 +27,19 @@ class CloudflareAdapter(BaseAdapter):
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.config.get('api_token', '')}"}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
def _request(self, method: str, path: str, json_body: dict = None) -> dict:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(self.BASE + path, headers=self._headers())
|
||||
resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
data = resp.json() if resp.content else {}
|
||||
if not data.get("success", True):
|
||||
errors = data.get("errors", [])
|
||||
raise RuntimeError(errors[0].get("message") if errors else "Cloudflare API 返回失败")
|
||||
return data
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
return self._request("GET", path)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
data = self._get("/user/tokens/verify")
|
||||
@@ -98,3 +108,84 @@ class CloudflareAdapter(BaseAdapter):
|
||||
if parsed:
|
||||
expiry[name] = parsed
|
||||
return expiry
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/zones/{zone_id}/dns_records?per_page=100&page={page}")
|
||||
for r in data.get("result", []):
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("id")),
|
||||
zone_id=zone_id,
|
||||
name=r.get("name"),
|
||||
type=r.get("type"),
|
||||
content=r.get("content"),
|
||||
ttl=r.get("ttl"),
|
||||
proxied=r.get("proxied"),
|
||||
priority=r.get("priority"),
|
||||
comment=r.get("comment"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
info = data.get("result_info", {})
|
||||
if page >= info.get("total_pages", 1):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _record_body(record: dict) -> dict:
|
||||
body = {
|
||||
"type": record.get("type", "A"),
|
||||
"name": record.get("name", ""),
|
||||
"content": record.get("content", ""),
|
||||
"ttl": record.get("ttl") or 1, # 1 = Cloudflare 的「自动」TTL
|
||||
}
|
||||
# proxied 只对 A/AAAA/CNAME 有意义,其余类型传了会被 API 拒绝
|
||||
if record.get("proxied") is not None and body["type"] in ("A", "AAAA", "CNAME"):
|
||||
body["proxied"] = bool(record["proxied"])
|
||||
if record.get("priority") is not None:
|
||||
body["priority"] = record["priority"]
|
||||
if record.get("comment"):
|
||||
body["comment"] = record["comment"]
|
||||
return body
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
try:
|
||||
data = self._request(
|
||||
"POST", f"/zones/{zone_id}/dns_records", self._record_body(record)
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("result", {}).get("id", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:创建失败(Token 可能缺 Zone:Edit)"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
try:
|
||||
self._request(
|
||||
"PATCH", f"/zones/{zone_id}/dns_records/{record_id}", self._record_body(record)
|
||||
)
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:更新失败(Token 可能缺 Zone:Edit)"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._request("DELETE", f"/zones/{zone_id}/dns_records/{record_id}")
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:删除失败(Token 可能缺 Zone:Edit)"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
API 文档:https://docs.digitalocean.com/reference/api/
|
||||
所需配置:{"api_key": "..."}
|
||||
支持功能:Droplet 清单、账户余额、域名清单、启停/重启、DNS 记录管理
|
||||
DNS 的 zone_id 即域名本身(DO 用域名字符串定位)。
|
||||
DO 为按小时计费的订阅制,无「续费」概念,故不实现 renew_instance。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
@@ -9,6 +12,7 @@ import httpx
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
@@ -24,11 +28,14 @@ class DigitalOceanAdapter(BaseAdapter):
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.config.get('api_key', '')}"}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
def _request(self, method: str, path: str, json_body: dict = None) -> dict:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(self.BASE + path, headers=self._headers())
|
||||
resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
return self._request("GET", path)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
@@ -73,7 +80,10 @@ class DigitalOceanAdapter(BaseAdapter):
|
||||
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
|
||||
disk_gb=d.get("disk"),
|
||||
status="active" if d.get("status") == "active" else (d.get("status") or "unknown"),
|
||||
# size.price_monthly 是官方规格月价,比留空让台账自己猜准得多
|
||||
monthly_cost=(d.get("size", {}) or {}).get("price_monthly"),
|
||||
currency="USD",
|
||||
charge_type="postpaid",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
@@ -102,3 +112,119 @@ class DigitalOceanAdapter(BaseAdapter):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _droplet_action(self, external_id: str, action_type: str, label: str) -> dict:
|
||||
try:
|
||||
data = self._request(
|
||||
"POST", f"/droplets/{external_id}/actions", {"type": action_type}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"{label}指令已下发",
|
||||
"raw": data.get("action", {}),
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"{label}失败:{e}"}
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
return self._droplet_action(external_id, "power_on", "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
# shutdown 是优雅关机;power_off 等同拔电源,有丢数据风险,不默认使用
|
||||
return self._droplet_action(external_id, "shutdown", "关机")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._droplet_action(external_id, "reboot", "重启")
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/domains/{zone_id}/records?per_page=100&page={page}")
|
||||
for r in data.get("domain_records", []):
|
||||
sub = r.get("name") or "@"
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("id")),
|
||||
zone_id=zone_id,
|
||||
name=self._fqdn(sub, zone_id),
|
||||
type=r.get("type"),
|
||||
content=r.get("data"),
|
||||
ttl=r.get("ttl"),
|
||||
priority=r.get("priority"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
if not data.get("links", {}).get("pages", {}).get("next"):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(sub: str, zone_id: str) -> str:
|
||||
"""DO 存的是子域(name,如 www / @),拼成完整域名供台账展示"""
|
||||
if not sub or sub == "@":
|
||||
return zone_id
|
||||
return f"{sub}.{zone_id}"
|
||||
|
||||
@staticmethod
|
||||
def _sub(name: str, zone_id: str) -> str:
|
||||
if name == zone_id:
|
||||
return "@"
|
||||
suffix = f".{zone_id}"
|
||||
return name[: -len(suffix)] if name.endswith(suffix) else name
|
||||
|
||||
def _record_body(self, zone_id: str, record: dict) -> dict:
|
||||
body = {
|
||||
"name": self._sub(record.get("name", ""), zone_id),
|
||||
"type": record.get("type", "A"),
|
||||
"data": record.get("content", ""),
|
||||
"ttl": record.get("ttl") or 1800,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
body["priority"] = record["priority"]
|
||||
return body
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
try:
|
||||
data = self._request(
|
||||
"POST", f"/domains/{zone_id}/records", self._record_body(zone_id, record)
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("domain_record", {}).get("id", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:创建失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
try:
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/domains/{zone_id}/records/{record_id}",
|
||||
self._record_body(zone_id, record),
|
||||
)
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:更新失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._request("DELETE", f"/domains/{zone_id}/records/{record_id}")
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:删除失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Linode(Akamai)适配器(REST API v4,Bearer Token)
|
||||
|
||||
API 文档:https://www.linode.com/docs/api/
|
||||
所需配置:{"api_key": "..."}(Personal Access Token)
|
||||
|
||||
覆盖:实例清单与启停/重启、账户余额、托管域名清单、DNS 记录增删改查。
|
||||
Linode 为按小时计费,无「续费」概念,故不实现 renew_instance。
|
||||
DNS 的 zone_id 是数字域名 ID(/domains 返回的 id),不是域名字符串。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
_STATUS_MAP = {"running": "active", "offline": "stopped", "booting": "active", "shutting_down": "stopped"}
|
||||
|
||||
|
||||
@register("linode-api")
|
||||
class LinodeAdapter(BaseAdapter):
|
||||
required_config = ["api_key"]
|
||||
BASE = "https://api.linode.com/v4"
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.config.get('api_key', '')}"}
|
||||
|
||||
def _request(self, method: str, path: str, json_body: dict = None) -> dict:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
return self._request("GET", path)
|
||||
|
||||
@staticmethod
|
||||
def _error_message(e: httpx.HTTPStatusError) -> str:
|
||||
"""Linode 把原因放在 body.errors[0].reason,比裸状态码有用得多"""
|
||||
try:
|
||||
errors = e.response.json().get("errors", [])
|
||||
if errors:
|
||||
return f"HTTP {e.response.status_code}:{errors[0].get('reason')}"
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return f"HTTP {e.response.status_code}"
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
data = self._get("/account")
|
||||
return {"ok": True, "message": f"连接成功,账户 {data.get('email') or data.get('company') or 'OK'}"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:Token 无效或权限不足"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
acc = self._get("/account")
|
||||
# Linode 的 balance 为负数表示账户有余额(预付信用),正数表示欠款
|
||||
return AccountInfo(
|
||||
balance=acc.get("balance"),
|
||||
currency="USD",
|
||||
raw=acc,
|
||||
)
|
||||
|
||||
def list_vps(self) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/linode/instances?page={page}&page_size=100")
|
||||
for inst in data.get("data", []):
|
||||
specs = inst.get("specs", {}) or {}
|
||||
mem_mb = specs.get("memory") or 0
|
||||
ipv4 = inst.get("ipv4") or []
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=str(inst.get("id")),
|
||||
name=inst.get("label") or str(inst.get("id")),
|
||||
ip_address=ipv4[0] if ipv4 else None,
|
||||
region=inst.get("region"),
|
||||
os=inst.get("image"),
|
||||
cpu_cores=specs.get("vcpus"),
|
||||
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
|
||||
disk_gb=specs.get("disk"),
|
||||
status=_STATUS_MAP.get(inst.get("status"), inst.get("status") or "unknown"),
|
||||
currency="USD",
|
||||
charge_type="postpaid",
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
if page >= (data.get("pages") or 1):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
def list_domains(self) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/domains?page={page}&page_size=100")
|
||||
for d in data.get("data", []):
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=str(d.get("id")),
|
||||
domain_name=d.get("domain"),
|
||||
registrar="linode",
|
||||
# 仅 Linode 注册的域名有 expiry;纯 DNS 托管的为 null
|
||||
expiry_date=parse_iso_date(d.get("expiry")),
|
||||
status="active" if d.get("status") == "active" else (d.get("status") or "unknown"),
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
if page >= (data.get("pages") or 1):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _instance_action(self, external_id: str, verb: str, label: str) -> dict:
|
||||
try:
|
||||
self._request("POST", f"/linode/instances/{external_id}/{verb}")
|
||||
return {"ok": True, "message": f"{label}指令已下发"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:{label}失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"{label}失败:{e}"}
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "boot", "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "shutdown", "关机")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "reboot", "重启")
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/domains/{zone_id}/records?page={page}&page_size=100")
|
||||
for r in data.get("data", []):
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("id")),
|
||||
zone_id=zone_id,
|
||||
name=r.get("name") or "",
|
||||
type=r.get("type"),
|
||||
content=r.get("target"),
|
||||
ttl=r.get("ttl_sec"),
|
||||
priority=r.get("priority"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
if page >= (data.get("pages") or 1):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _record_body(record: dict) -> dict:
|
||||
body = {
|
||||
"type": record.get("type", "A"),
|
||||
"name": record.get("name", ""),
|
||||
"target": record.get("content", ""),
|
||||
"ttl_sec": record.get("ttl") or 300,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
body["priority"] = record["priority"]
|
||||
return body
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
try:
|
||||
data = self._request("POST", f"/domains/{zone_id}/records", self._record_body(record))
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("id", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:创建失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
try:
|
||||
self._request("PUT", f"/domains/{zone_id}/records/{record_id}", self._record_body(record))
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:更新失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._request("DELETE", f"/domains/{zone_id}/records/{record_id}")
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"{self._error_message(e)}:删除失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Namesilo 适配器(REST API v1,API Key 查询参数)
|
||||
|
||||
API 文档:https://www.namesilo.com/api_reference.php
|
||||
所需配置:{"api_key": "..."}
|
||||
|
||||
覆盖:注册域名清单(含到期日)、DNS 记录增删改查。
|
||||
Namesilo 是纯域名注册商,无 VPS,故不实现 list_vps / 实例动作。
|
||||
|
||||
响应约定:所有命令都返回 {"request": ..., "reply": {...}},
|
||||
reply.code == 300 才是成功,其余(如 301 未授权、400 参数错)需读 reply.detail。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import (
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
SUCCESS_CODE = 300
|
||||
|
||||
|
||||
@register("namesilo-api")
|
||||
class NamesiloAdapter(BaseAdapter):
|
||||
required_config = ["api_key"]
|
||||
BASE = "https://www.namesilo.com/api"
|
||||
|
||||
def _call(self, command: str, params: dict = None) -> dict:
|
||||
query = {"version": "1", "type": "json", "key": self.config.get("api_key", "")}
|
||||
if params:
|
||||
query.update({k: v for k, v in params.items() if v is not None})
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(f"{self.BASE}/{command}", params=query)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
reply = data.get("reply", {}) or {}
|
||||
code = reply.get("code")
|
||||
# Namesilo 的 code 可能是 int 也可能是 str,统一按 int 比较
|
||||
try:
|
||||
code = int(code)
|
||||
except (TypeError, ValueError):
|
||||
code = None
|
||||
if code != SUCCESS_CODE:
|
||||
raise RuntimeError(f"Namesilo {command} 失败(code={reply.get('code')}):{reply.get('detail')}")
|
||||
return reply
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
reply = self._call("checkApiKey")
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"连接成功,令牌属于 {reply.get('username') or '未知账户'}",
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:API Key 无效"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def list_domains(self) -> list:
|
||||
reply = self._call("listDomains")
|
||||
result = []
|
||||
for d in reply.get("domains", []) or []:
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=d.get("domain"),
|
||||
domain_name=d.get("domain"),
|
||||
registrar="namesilo",
|
||||
# expires 形如 "2027-01-01";Namesilo 也可能给 "Jan 01, 2027",解析失败归 None
|
||||
expiry_date=parse_iso_date(d.get("expires")),
|
||||
status="active",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
# zone_id 即域名字符串;record_id 用 Namesilo 的 record_id
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
reply = self._call("listDnsRecords", {"domain": zone_id})
|
||||
result = []
|
||||
for r in reply.get("dns_records", reply.get("resource_record", [])) or []:
|
||||
host = r.get("host") or ""
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("record_id") or r.get("id") or ""),
|
||||
zone_id=zone_id,
|
||||
name=self._fqdn(host, zone_id),
|
||||
type=r.get("type"),
|
||||
content=r.get("value"),
|
||||
ttl=int(r["ttl"]) if r.get("ttl") else None,
|
||||
priority=int(r["distance"]) if r.get("distance") else None,
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(host: str, zone_id: str) -> str:
|
||||
"""Namesilo 的 host 是子域(空或 @ 表示裸域),拼成完整域名供台账展示"""
|
||||
if not host or host == "@":
|
||||
return zone_id
|
||||
return f"{host}.{zone_id}"
|
||||
|
||||
@staticmethod
|
||||
def _host(name: str, zone_id: str) -> str:
|
||||
"""反向:把完整域名拆回子域(裸域用空串)"""
|
||||
if not name or name == zone_id:
|
||||
return ""
|
||||
suffix = f".{zone_id}"
|
||||
return name[: -len(suffix)] if name.endswith(suffix) else name
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
# Namesilo 用 dnsRecordUpdate 同时承担新增(不带 record_id)与修改(带 record_id)
|
||||
params = {
|
||||
"domain": zone_id,
|
||||
"rr_type": record.get("type", "A"),
|
||||
"rr_host": self._host(record.get("name", ""), zone_id),
|
||||
"rr_value": record.get("content", ""),
|
||||
"rr_ttl": record.get("ttl") or 3600,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
params["rr_distance"] = record["priority"]
|
||||
try:
|
||||
self._call("dnsRecordUpdate", params)
|
||||
return {"ok": True, "message": "记录已创建"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:创建失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
params = {
|
||||
"domain": zone_id,
|
||||
"rr_id": record_id,
|
||||
"rr_type": record.get("type", "A"),
|
||||
"rr_host": self._host(record.get("name", ""), zone_id),
|
||||
"rr_value": record.get("content", ""),
|
||||
"rr_ttl": record.get("ttl") or 3600,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
params["rr_distance"] = record["priority"]
|
||||
try:
|
||||
self._call("dnsRecordUpdate", params)
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:更新失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._call("dnsRecordDelete", {"domain": zone_id, "rr_id": record_id})
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:删除失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
@@ -28,8 +28,11 @@ def load_all() -> None:
|
||||
from app.adapters import ( # noqa: F401
|
||||
ai,
|
||||
aliyun,
|
||||
aws,
|
||||
cloudflare,
|
||||
digitalocean,
|
||||
linode,
|
||||
namesilo,
|
||||
tencent,
|
||||
vultr,
|
||||
)
|
||||
|
||||
+257
-23
@@ -1,10 +1,16 @@
|
||||
"""腾讯云适配器(含国际版,CVM OpenAPI + TC3-HMAC-SHA256 签名)
|
||||
"""腾讯云适配器(含国际版,TC3-HMAC-SHA256 签名)
|
||||
|
||||
API 文档:https://cloud.tencent.com/document/api/213/15753
|
||||
所需配置:{"secret_id": "...", "secret_key": "...", "region": "ap-guangzhou"}
|
||||
国际版(intl)将 region 设为海外区域即可,端点统一为 cvm.tencentcloudapi.com。
|
||||
说明:此处实现 TC3-HMAC-SHA256 签名,可直接调用 CVM OpenAPI;
|
||||
生产环境也可替换为官方 tencentcloud-sdk-python,接口契约保持不变。
|
||||
国际版(intl)将 region 设为海外区域即可,端点统一为 *.tencentcloudapi.com。
|
||||
|
||||
TC3 签名对各产品一致,只有 host / service / version 不同,故 _call 接受这三者:
|
||||
- CVM cvm.tencentcloudapi.com 2017-03-12 实例清单与启停/重启/续费
|
||||
- Billing billing.tencentcloudapi.com 2018-07-09 账户余额(单位:分)
|
||||
- Domain domain.tencentcloudapi.com 2018-08-08 注册域名清单(含到期日)
|
||||
- DNSPod dnspod.tencentcloudapi.com 2021-03-23 解析记录增删改查
|
||||
|
||||
DNS 的 zone_id 即域名本身(DNSPod 用域名而非 zone uuid 定位)。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -14,17 +20,39 @@ import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.base import BaseAdapter, NormalizedVPS
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
)
|
||||
from app.adapters.registry import register
|
||||
|
||||
CVM_HOST = "cvm.tencentcloudapi.com"
|
||||
CVM_VERSION = "2017-03-12"
|
||||
BILLING_HOST = "billing.tencentcloudapi.com"
|
||||
BILLING_VERSION = "2018-07-09"
|
||||
DOMAIN_HOST = "domain.tencentcloudapi.com"
|
||||
DOMAIN_VERSION = "2018-08-08"
|
||||
DNSPOD_HOST = "dnspod.tencentcloudapi.com"
|
||||
DNSPOD_VERSION = "2021-03-23"
|
||||
|
||||
ALGORITHM = "TC3-HMAC-SHA256"
|
||||
|
||||
_STATUS_MAP = {"RUNNING": "active", "STOPPED": "stopped", "STOPPING": "stopped", "STARTING": "active"}
|
||||
_CHARGE_TYPE_MAP = {"PREPAID": "prepaid", "POSTPAID_BY_HOUR": "postpaid", "POSTPAID_BY_MONTH": "postpaid"}
|
||||
|
||||
|
||||
def _service_of(host: str) -> str:
|
||||
"""从 host 推导 TC3 credential scope 用的 service 名"""
|
||||
return host.split(".")[0]
|
||||
|
||||
|
||||
@register("tencent-sdk", "tencent-intl-sdk")
|
||||
class TencentAdapter(BaseAdapter):
|
||||
required_config = ["secret_id", "secret_key"]
|
||||
HOST = "cvm.tencentcloudapi.com"
|
||||
SERVICE = "cvm"
|
||||
VERSION = "2017-03-12"
|
||||
ALGORITHM = "TC3-HMAC-SHA256"
|
||||
|
||||
def _region(self) -> str:
|
||||
return self.config.get("region") or "ap-guangzhou"
|
||||
@@ -33,48 +61,62 @@ class TencentAdapter(BaseAdapter):
|
||||
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
||||
|
||||
def _build_authorization(self, action: str, payload_json: str, timestamp: int, date: str):
|
||||
def _build_authorization(
|
||||
self, action: str, payload_json: str, timestamp: int, date: str, host: str, service: str
|
||||
):
|
||||
secret_id = self.config.get("secret_id", "")
|
||||
secret_key = self.config.get("secret_key", "")
|
||||
content_type = "application/json; charset=utf-8"
|
||||
canonical_headers = (
|
||||
f"content-type:{content_type}\nhost:{self.HOST}\nx-tc-action:{action.lower()}\n"
|
||||
f"content-type:{content_type}\nhost:{host}\nx-tc-action:{action.lower()}\n"
|
||||
)
|
||||
signed_headers = "content-type;host;x-tc-action"
|
||||
hashed_payload = hashlib.sha256(payload_json.encode()).hexdigest()
|
||||
canonical_request = (
|
||||
f"POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_payload}"
|
||||
)
|
||||
credential_scope = f"{date}/{self.SERVICE}/tc3_request"
|
||||
credential_scope = f"{date}/{service}/tc3_request"
|
||||
hashed_canonical = hashlib.sha256(canonical_request.encode()).hexdigest()
|
||||
string_to_sign = (
|
||||
f"{self.ALGORITHM}\n{timestamp}\n{credential_scope}\n{hashed_canonical}"
|
||||
f"{ALGORITHM}\n{timestamp}\n{credential_scope}\n{hashed_canonical}"
|
||||
)
|
||||
secret_date = self._hmac_sha256(("TC3" + secret_key).encode(), date)
|
||||
secret_service = self._hmac_sha256(secret_date, self.SERVICE)
|
||||
secret_service = self._hmac_sha256(secret_date, service)
|
||||
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
|
||||
signature = hmac.new(secret_signing, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
||||
return (
|
||||
f"{self.ALGORITHM} Credential={secret_id}/{credential_scope}, "
|
||||
f"{ALGORITHM} Credential={secret_id}/{credential_scope}, "
|
||||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
|
||||
def _call(self, action: str, payload: dict = None) -> dict:
|
||||
def _call(
|
||||
self,
|
||||
action: str,
|
||||
payload: dict = None,
|
||||
host: str = CVM_HOST,
|
||||
version: str = CVM_VERSION,
|
||||
with_region: bool = True,
|
||||
) -> dict:
|
||||
payload_json = json.dumps(payload or {})
|
||||
timestamp = int(time.time())
|
||||
date = time.strftime("%Y-%m-%d", time.gmtime(timestamp))
|
||||
authorization = self._build_authorization(action, payload_json, timestamp, date)
|
||||
service = _service_of(host)
|
||||
authorization = self._build_authorization(
|
||||
action, payload_json, timestamp, date, host, service
|
||||
)
|
||||
headers = {
|
||||
"Authorization": authorization,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Host": self.HOST,
|
||||
"Host": host,
|
||||
"X-TC-Action": action,
|
||||
"X-TC-Timestamp": str(timestamp),
|
||||
"X-TC-Version": self.VERSION,
|
||||
"X-TC-Region": self._region(),
|
||||
"X-TC-Version": version,
|
||||
}
|
||||
# Billing / Domain / DNSPod 是中心化服务,带 Region 反而可能报参数错误
|
||||
if with_region:
|
||||
headers["X-TC-Region"] = self._region()
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.post(f"https://{self.HOST}", content=payload_json, headers=headers)
|
||||
resp = client.post(f"https://{host}", content=payload_json, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
response = data.get("Response", {})
|
||||
@@ -93,16 +135,18 @@ class TencentAdapter(BaseAdapter):
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
# ---------------- 只读清单 ----------------
|
||||
|
||||
def list_vps(self) -> list:
|
||||
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
|
||||
result = []
|
||||
status_map = {"RUNNING": "active", "STOPPED": "stopped"}
|
||||
offset = 0
|
||||
while True:
|
||||
data = self._call("DescribeInstances", {"Limit": 100, "Offset": offset})
|
||||
inst_set = data.get("InstanceSet", [])
|
||||
for inst in inst_set:
|
||||
public_ips = inst.get("PublicIpAddresses", [])
|
||||
charge = inst.get("InstanceChargeType")
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("InstanceId"),
|
||||
@@ -113,8 +157,11 @@ class TencentAdapter(BaseAdapter):
|
||||
cpu_cores=inst.get("CPU"),
|
||||
memory_gb=inst.get("Memory"),
|
||||
disk_gb=inst.get("SystemDisk", {}).get("DiskSize"),
|
||||
status=status_map.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"),
|
||||
status=_STATUS_MAP.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"),
|
||||
currency="CNY",
|
||||
# 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效
|
||||
expiry_date=parse_iso_date(inst.get("ExpiredTime")),
|
||||
charge_type=_CHARGE_TYPE_MAP.get(charge, charge),
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
@@ -123,3 +170,190 @@ class TencentAdapter(BaseAdapter):
|
||||
if not inst_set or offset >= total:
|
||||
break
|
||||
return result
|
||||
|
||||
def list_domains(self) -> list:
|
||||
"""注册域名清单(domain.tencentcloudapi.com DescribeDomainNameList)"""
|
||||
result = []
|
||||
offset = 0
|
||||
while True:
|
||||
data = self._call(
|
||||
"DescribeDomainNameList",
|
||||
{"Offset": offset, "Limit": 100},
|
||||
host=DOMAIN_HOST,
|
||||
version=DOMAIN_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
domains = data.get("DomainSet", [])
|
||||
for d in domains:
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=d.get("DomainName") or d.get("DomainId"),
|
||||
domain_name=d.get("DomainName"),
|
||||
registrar="tencent",
|
||||
# 该接口返回 ExpireDate(毫秒时间戳)或 ExpireTime(ISO),两者都兼容
|
||||
expiry_date=parse_iso_date(d.get("ExpireTime") or self._ms_to_iso(d.get("ExpireDate"))),
|
||||
status="active",
|
||||
raw=d,
|
||||
)
|
||||
)
|
||||
total = int(data.get("TotalCount") or 0)
|
||||
offset += len(domains)
|
||||
if not domains or offset >= total:
|
||||
break
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _ms_to_iso(value) -> str:
|
||||
"""毫秒时间戳 -> ISO 字符串(parse_iso_date 只取前 10 位)"""
|
||||
try:
|
||||
return time.strftime("%Y-%m-%d", time.gmtime(int(value) / 1000))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
"""账户余额(billing.tencentcloudapi.com DescribeAccountBalance)
|
||||
|
||||
注意 Balance 单位是「分」,必须除 100 才是元,否则台账金额会放大百倍。
|
||||
"""
|
||||
data = self._call(
|
||||
"DescribeAccountBalance",
|
||||
host=BILLING_HOST,
|
||||
version=BILLING_VERSION,
|
||||
with_region=False,
|
||||
)
|
||||
balance = data.get("Balance")
|
||||
return AccountInfo(
|
||||
balance=round(balance / 100, 2) if isinstance(balance, (int, float)) else None,
|
||||
currency="CNY",
|
||||
raw=data,
|
||||
)
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _action(self, action: str, payload: dict, label: str) -> dict:
|
||||
try:
|
||||
data = self._call(action, payload)
|
||||
return {"ok": True, "message": f"{label}指令已下发", "raw": data}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"{label}失败:{e}"}
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
return self._action("StartInstances", {"InstanceIds": [external_id]}, "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
# SOFT_FIRST:先正常关机,超时才强制;直接 HARD 有丢数据风险
|
||||
return self._action(
|
||||
"StopInstances",
|
||||
{"InstanceIds": [external_id], "StopType": "SOFT_FIRST", "StoppedBehavior": "KEEP_CHARGING"},
|
||||
"关机",
|
||||
)
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._action(
|
||||
"RebootInstances", {"InstanceIds": [external_id], "RebootType": "SOFT_FIRST"}, "重启"
|
||||
)
|
||||
|
||||
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
|
||||
# 仅对 PREPAID 实例有效;Period 单位为月
|
||||
return self._action(
|
||||
"RenewInstances",
|
||||
{
|
||||
"InstanceIds": [external_id],
|
||||
"InstanceChargePrepaid": {
|
||||
"RenewFlag": "NOTIFY_AND_MANUAL_RENEW",
|
||||
"Period": period_months,
|
||||
},
|
||||
},
|
||||
f"续费 {period_months} 个月",
|
||||
)
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def _dnspod(self, action: str, payload: dict) -> dict:
|
||||
return self._call(
|
||||
action, payload, host=DNSPOD_HOST, version=DNSPOD_VERSION, with_region=False
|
||||
)
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
data = self._dnspod("DescribeRecordList", {"Domain": zone_id})
|
||||
result = []
|
||||
for r in data.get("RecordList", []):
|
||||
sub = r.get("Name") or "@"
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("RecordId") or r.get("ID")),
|
||||
zone_id=zone_id,
|
||||
name=self._fqdn(sub, zone_id),
|
||||
type=r.get("Type"),
|
||||
content=r.get("Value"),
|
||||
ttl=int(r.get("TTL")) if r.get("TTL") else None,
|
||||
priority=int(r["MX"]) if r.get("MX") else None,
|
||||
comment=r.get("Comment"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _fqdn(sub: str, zone_id: str) -> str:
|
||||
"""DNSPod 存的是子域(Name,如 www / @),拼成完整域名供台账展示"""
|
||||
if not sub or sub == "@":
|
||||
return zone_id
|
||||
return f"{sub}.{zone_id}"
|
||||
|
||||
@staticmethod
|
||||
def _sub(name: str, zone_id: str) -> str:
|
||||
"""反向:把完整域名拆回子域"""
|
||||
if name == zone_id:
|
||||
return "@"
|
||||
suffix = f".{zone_id}"
|
||||
return name[: -len(suffix)] if name.endswith(suffix) else name
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
payload = {
|
||||
"Domain": zone_id,
|
||||
"SubDomain": self._sub(record.get("name", ""), zone_id),
|
||||
"RecordType": record.get("type", "A"),
|
||||
"RecordLine": record.get("line") or "默认",
|
||||
"Value": record.get("content", ""),
|
||||
"TTL": record.get("ttl") or 600,
|
||||
}
|
||||
if record.get("priority"):
|
||||
payload["MX"] = record["priority"]
|
||||
try:
|
||||
data = self._dnspod("CreateRecord", payload)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("RecordId", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
payload = {
|
||||
"Domain": zone_id,
|
||||
"RecordId": record_id,
|
||||
"SubDomain": self._sub(record.get("name", ""), zone_id),
|
||||
"RecordType": record.get("type", "A"),
|
||||
"RecordLine": record.get("line") or "默认",
|
||||
"Value": record.get("content", ""),
|
||||
"TTL": record.get("ttl") or 600,
|
||||
}
|
||||
if record.get("priority"):
|
||||
payload["MX"] = record["priority"]
|
||||
try:
|
||||
data = self._dnspod("ModifyRecord", payload)
|
||||
return {"ok": True, "message": "记录已更新", "raw": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
data = self._dnspod("DeleteRecord", {"Domain": zone_id, "RecordId": record_id})
|
||||
return {"ok": True, "message": "记录已删除", "raw": data}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
|
||||
+109
-4
@@ -2,7 +2,9 @@
|
||||
|
||||
API 文档:https://www.vultr.com/api/
|
||||
所需配置:{"api_key": "..."}
|
||||
支持功能:实例列表、订阅账单、区域查询
|
||||
支持功能:实例列表、订阅账单、区域查询、实例启停/重启、DNS 记录管理
|
||||
DNS 的 zone_id 即域名本身(Vultr 用域名字符串定位,非 uuid)。
|
||||
Vultr 为按小时计费的订阅制,无「续费」概念,故不实现 renew_instance。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
@@ -10,6 +12,7 @@ import httpx
|
||||
from app.adapters.base import (
|
||||
AccountInfo,
|
||||
BaseAdapter,
|
||||
NormalizedDNSRecord,
|
||||
NormalizedDomain,
|
||||
NormalizedVPS,
|
||||
parse_iso_date,
|
||||
@@ -25,11 +28,15 @@ class VultrAdapter(BaseAdapter):
|
||||
def _headers(self) -> dict:
|
||||
return {"Authorization": f"Bearer {self.config.get('api_key', '')}"}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
def _request(self, method: str, path: str, json_body: dict = None) -> dict:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(self.BASE + path, headers=self._headers())
|
||||
resp = client.request(method, self.BASE + path, headers=self._headers(), json=json_body)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
# Vultr 的动作类接口成功时返回 204 无 body
|
||||
return resp.json() if resp.content else {}
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
return self._request("GET", path)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
try:
|
||||
@@ -73,6 +80,8 @@ class VultrAdapter(BaseAdapter):
|
||||
status="active" if inst.get("status") == "active" else (inst.get("status") or "unknown"),
|
||||
monthly_cost=inst.get("monthly_cost"),
|
||||
currency="USD",
|
||||
# Vultr 按小时计费、无到期概念,标 postpaid 让前端不去催续费
|
||||
charge_type="postpaid",
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
@@ -104,3 +113,99 @@ class VultrAdapter(BaseAdapter):
|
||||
if not cursor:
|
||||
break
|
||||
return result
|
||||
|
||||
# ---------------- 实例管理动作 ----------------
|
||||
|
||||
def _instance_action(self, external_id: str, verb: str, label: str) -> dict:
|
||||
try:
|
||||
self._request("POST", f"/instances/{external_id}/{verb}")
|
||||
return {"ok": True, "message": f"{label}指令已下发"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"{label}失败:{e}"}
|
||||
|
||||
def start_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "start", "开机")
|
||||
|
||||
def stop_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "halt", "关机")
|
||||
|
||||
def reboot_instance(self, external_id: str) -> dict:
|
||||
return self._instance_action(external_id, "reboot", "重启")
|
||||
|
||||
# ---------------- DNS 记录管理 ----------------
|
||||
|
||||
def list_dns_records(self, zone_id: str) -> list:
|
||||
result = []
|
||||
cursor = ""
|
||||
while True:
|
||||
path = f"/domains/{zone_id}/records?per_page=100"
|
||||
if cursor:
|
||||
path += f"&cursor={cursor}"
|
||||
data = self._get(path)
|
||||
for r in data.get("records", []):
|
||||
result.append(
|
||||
NormalizedDNSRecord(
|
||||
record_id=str(r.get("id")),
|
||||
zone_id=zone_id,
|
||||
name=r.get("name") or zone_id,
|
||||
type=r.get("type"),
|
||||
content=r.get("data"),
|
||||
ttl=r.get("ttl"),
|
||||
priority=r.get("priority"),
|
||||
raw=r,
|
||||
)
|
||||
)
|
||||
cursor = data.get("meta", {}).get("links", {}).get("next") or ""
|
||||
if not cursor:
|
||||
break
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _record_body(record: dict) -> dict:
|
||||
body = {
|
||||
"name": record.get("name", ""),
|
||||
"type": record.get("type", "A"),
|
||||
"data": record.get("content", ""),
|
||||
"ttl": record.get("ttl") or 300,
|
||||
}
|
||||
if record.get("priority") is not None:
|
||||
body["priority"] = record["priority"]
|
||||
return body
|
||||
|
||||
def create_dns_record(self, zone_id: str, record: dict) -> dict:
|
||||
try:
|
||||
data = self._request(
|
||||
"POST", f"/domains/{zone_id}/records", self._record_body(record)
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "记录已创建",
|
||||
"record_id": str(data.get("record", {}).get("id", "")),
|
||||
"raw": data,
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:创建失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"创建失败:{e}"}
|
||||
|
||||
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
|
||||
try:
|
||||
self._request(
|
||||
"PATCH", f"/domains/{zone_id}/records/{record_id}", self._record_body(record)
|
||||
)
|
||||
return {"ok": True, "message": "记录已更新"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:更新失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"更新失败:{e}"}
|
||||
|
||||
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
|
||||
try:
|
||||
self._request("DELETE", f"/domains/{zone_id}/records/{record_id}")
|
||||
return {"ok": True, "message": "记录已删除"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return {"ok": False, "message": f"HTTP {e.response.status_code}:删除失败"}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": f"删除失败:{e}"}
|
||||
|
||||
Reference in New Issue
Block a user