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}"}
|
||||
|
||||
+12
-6
@@ -23,21 +23,27 @@ PRESET_PROVIDERS = [
|
||||
{"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"},
|
||||
{"slug": "aws", "name": "AWS", "name_en": "Amazon Web Services", "category": "vps",
|
||||
"services": "vps,domain,dns,cdn,ssl_cert",
|
||||
"website": "https://aws.amazon.com", "console_url": "https://console.aws.amazon.com/ec2/", "sdk_type": "aws-api"},
|
||||
{"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps",
|
||||
"services": "vps",
|
||||
"services": "vps,domain,dns",
|
||||
"website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
|
||||
{"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps",
|
||||
"services": "vps,domain",
|
||||
"services": "vps,domain,dns",
|
||||
"website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
|
||||
{"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps",
|
||||
"services": "vps",
|
||||
"services": "vps,domain,dns",
|
||||
"website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
|
||||
# 以下平台暂无适配器:sdk_type 必须留空。
|
||||
# 填了不存在的 sdk_type 会让平台卡片显示「API 对接: xxx」,用户建账号点同步才撞
|
||||
# 「暂不支持的 SDK 类型」——声明支持却不支持,比明说不支持更糟。
|
||||
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
|
||||
"services": "vps",
|
||||
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
|
||||
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": None},
|
||||
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
|
||||
"services": "vps,domain",
|
||||
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
|
||||
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": None},
|
||||
# ---- 域名注册商 ----
|
||||
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
@@ -47,7 +53,7 @@ PRESET_PROVIDERS = [
|
||||
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
|
||||
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
|
||||
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": None},
|
||||
# ---- AI 服务商 ----
|
||||
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
|
||||
+46
-14
@@ -98,8 +98,8 @@ def _migrate_assets_db() -> None:
|
||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME"))
|
||||
if "services" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN services VARCHAR DEFAULT ''"))
|
||||
# 为已有预设平台补充 services(仅补空值,用户自定义行不动)
|
||||
_backfill_provider_services(conn)
|
||||
# 预设平台的 sdk_type / services 对齐当前代码能力(幂等,用户自建行不动)
|
||||
_align_preset_providers(conn)
|
||||
if insp.has_table("ai_accounts"):
|
||||
cols = {c["name"] for c in insp.get_columns("ai_accounts")}
|
||||
if "api_key_encrypted" not in cols:
|
||||
@@ -182,22 +182,54 @@ def _migrate_plaintext_api_keys(conn) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _backfill_provider_services(conn) -> None:
|
||||
"""为已有预设平台补充 services 服务列表(只更新 services 为空的预设 slug)"""
|
||||
def _align_preset_providers(conn) -> None:
|
||||
"""把预设平台的 sdk_type / services 对齐到当前代码能力(幂等)
|
||||
|
||||
seed_providers 只插入缺失 slug、不更新已有行,所以代码侧的能力变化
|
||||
(新增适配器、废弃从未实现的 sdk_type)必须在这里落地,否则线上会长期
|
||||
停留在旧声明上。
|
||||
|
||||
- sdk_type:库里声明的值不在适配器注册表内就清空。留着会让平台卡片显示
|
||||
「API 对接: xxx」,用户建好账号点同步才撞「暂不支持的 SDK 类型」——
|
||||
声明支持却不支持,比明说不支持更糟。
|
||||
- services:只增量补标签,不删除。用户可能手动加过标签(线上 aliyun-intl
|
||||
就多了 ai_agent/other),按种子值整体覆盖会丢用户配置。
|
||||
|
||||
只处理 slug 命中 PRESET_PROVIDERS 的行,用户自建平台(如 tothost)不动。
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from app.adapters import registry
|
||||
from app.core.seed import PRESET_PROVIDERS
|
||||
|
||||
for data in PRESET_PROVIDERS:
|
||||
services = data.get("services")
|
||||
if not services:
|
||||
supported = set(registry.supported_types())
|
||||
preset = {d["slug"]: d for d in PRESET_PROVIDERS}
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, slug, sdk_type, services FROM providers")
|
||||
).fetchall()
|
||||
|
||||
for row in rows:
|
||||
data = preset.get(row[1])
|
||||
if not data:
|
||||
continue
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE providers SET services = :services "
|
||||
"WHERE slug = :slug AND (services IS NULL OR services = '')"
|
||||
),
|
||||
{"services": services, "slug": data["slug"]},
|
||||
)
|
||||
|
||||
declared = row[2]
|
||||
if declared and declared not in supported:
|
||||
conn.execute(
|
||||
sa.text("UPDATE providers SET sdk_type = NULL WHERE id = :id"),
|
||||
{"id": row[0]},
|
||||
)
|
||||
|
||||
seed_services = [s.strip() for s in (data.get("services") or "").split(",") if s.strip()]
|
||||
if not seed_services:
|
||||
continue
|
||||
current = [s.strip() for s in (row[3] or "").split(",") if s.strip()]
|
||||
missing = [s for s in seed_services if s not in current]
|
||||
if missing:
|
||||
conn.execute(
|
||||
sa.text("UPDATE providers SET services = :services WHERE id = :id"),
|
||||
{"services": ",".join(current + missing), "id": row[0]},
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_accounts(conn) -> None:
|
||||
|
||||
+2
-1
@@ -23,7 +23,7 @@ from sqlmodel import Session
|
||||
from app.core.config import settings
|
||||
from app.core.seed import seed_providers
|
||||
from app.database import assets_engine, init_db
|
||||
from app.routers import accounts, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
|
||||
from app.routers import accounts, actions, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
|
||||
from app.services import cleanup_service
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -138,6 +138,7 @@ app.include_router(stats.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(monitor.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(actions.router)
|
||||
app.include_router(notify.router)
|
||||
app.include_router(ssl.router)
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""云资源管理动作路由
|
||||
|
||||
统一入口 /api/actions/{asset_id},把资产解析成对应平台适配器后下发真实云端指令。
|
||||
|
||||
安全约束:
|
||||
- 所有写操作(实例动作、DNS 增删改)强制 require_api_key,与项目其余写端点一致
|
||||
- 每次动作由 action_service 落 EventLog 审计(source="action"),成功失败都记
|
||||
- 能力探测端点 GET /capabilities 无副作用,供前端预先禁用不支持的按钮
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_session
|
||||
from app.schemas.action import DNSRecordIn, InstanceActionRequest
|
||||
from app.services import action_service
|
||||
|
||||
router = APIRouter(prefix="/api/actions", tags=["actions"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/capabilities",
|
||||
summary="查询该资产可执行的动作(前端据此渲染/禁用按钮)",
|
||||
)
|
||||
def capabilities(asset_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return action_service.asset_actions(session, asset_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/instance",
|
||||
summary="实例动作:开机 / 关机 / 重启 / 续费(真实操作云端资源)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def instance_action(
|
||||
asset_id: int, data: InstanceActionRequest, session: Session = Depends(get_session)
|
||||
) -> dict:
|
||||
return action_service.perform_instance_action(
|
||||
session, asset_id, data.action, data.period_months
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/dns",
|
||||
summary="列出该域名的 DNS 解析记录",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def list_dns(asset_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return action_service.list_dns_records(session, asset_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/dns",
|
||||
summary="新增 DNS 解析记录",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_dns(
|
||||
asset_id: int, record: DNSRecordIn, session: Session = Depends(get_session)
|
||||
) -> dict:
|
||||
return action_service.create_dns_record(session, asset_id, record.model_dump())
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{asset_id}/dns/{record_id}",
|
||||
summary="修改 DNS 解析记录",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def update_dns(
|
||||
asset_id: int,
|
||||
record_id: str,
|
||||
record: DNSRecordIn,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
return action_service.update_dns_record(
|
||||
session, asset_id, record_id, record.model_dump()
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{asset_id}/dns/{record_id}",
|
||||
summary="删除 DNS 解析记录",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_dns(
|
||||
asset_id: int, record_id: str, session: Session = Depends(get_session)
|
||||
) -> dict:
|
||||
return action_service.delete_dns_record(session, asset_id, record_id)
|
||||
@@ -17,3 +17,12 @@ router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
)
|
||||
def sync_ai_balances(session: Session = Depends(get_session)) -> dict:
|
||||
return sync_service.sync_all_ai_balances(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/assets",
|
||||
summary="批量同步所有已配凭证账号的云端资产(VPS / 域名)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def sync_assets(session: Session = Depends(get_session)) -> dict:
|
||||
return sync_service.sync_all_assets(session)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""云资源管理动作相关 Schema
|
||||
|
||||
动作请求体单独建模而非直接透传 dict:period_months 必须有上界,
|
||||
否则一次误传 9999 就会真实扣掉一笔巨额续费账单。
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
#: 允许的实例动作
|
||||
VALID_ACTIONS = ("start", "stop", "reboot", "renew")
|
||||
|
||||
#: 允许的 DNS 记录类型(各厂商交集,避免写入后被 API 拒绝)
|
||||
VALID_RECORD_TYPES = (
|
||||
"A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "LOC", "URL",
|
||||
)
|
||||
|
||||
|
||||
class InstanceActionRequest(SQLModel):
|
||||
"""实例动作请求"""
|
||||
|
||||
action: str = Field(description="start/stop/reboot/renew")
|
||||
period_months: int = Field(default=1, ge=1, le=60, description="续费周期(月),仅 renew 用")
|
||||
|
||||
@field_validator("action")
|
||||
@classmethod
|
||||
def _check_action(cls, v: str) -> str:
|
||||
if v not in VALID_ACTIONS:
|
||||
raise ValueError(f"action 必须是 {'/'.join(VALID_ACTIONS)} 之一,收到:{v}")
|
||||
return v
|
||||
|
||||
|
||||
class DNSRecordIn(SQLModel):
|
||||
"""DNS 记录输入(create/update 共用)
|
||||
|
||||
name 传完整域名(如 www.example.com)或子域(如 www)均可,
|
||||
各适配器按自身约定拆分;content 即记录值。
|
||||
"""
|
||||
|
||||
name: str = Field(min_length=1, description="记录名,完整域名或子域")
|
||||
type: str = Field(default="A", description="记录类型")
|
||||
content: str = Field(min_length=1, description="记录值")
|
||||
ttl: Optional[int] = Field(default=None, ge=60, le=86400, description="TTL 秒,留空用厂商默认")
|
||||
proxied: Optional[bool] = Field(default=None, description="Cloudflare 专用:是否走代理")
|
||||
priority: Optional[int] = Field(default=None, ge=0, le=65535, description="MX/SRV 优先级")
|
||||
comment: Optional[str] = Field(default=None, max_length=255, description="备注")
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def _check_type(cls, v: str) -> str:
|
||||
upper = (v or "").upper()
|
||||
if upper not in VALID_RECORD_TYPES:
|
||||
raise ValueError(f"不支持的记录类型:{v}(可选 {'/'.join(VALID_RECORD_TYPES)})")
|
||||
return upper
|
||||
@@ -186,6 +186,10 @@ class AssetRead(AssetBase):
|
||||
days_to_expiry: Optional[int] = None
|
||||
provider_name: Optional[str] = None
|
||||
account_name: Optional[str] = None # 账号标识(展示用,由 service 按 account_id 填充)
|
||||
# 云端实例 ID:只出不进(AssetCreate/AssetUpdate 不收,由 sync_service 写入)。
|
||||
# 前端据此判断「云操作」「DNS」按钮是否渲染——没有它按钮永远出不来,
|
||||
# 手工登记的资产则会渲染出点了必 400 的按钮。
|
||||
external_id: Optional[str] = None
|
||||
vps_detail: Optional[VPSDetailRead] = None
|
||||
domain_detail: Optional[DomainDetailRead] = None
|
||||
ai_detail: Optional[AIAccountRead] = None
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""云资源管理动作服务
|
||||
|
||||
把「资产 → 账号 → 平台适配器」解析成一次真实的云端写操作。
|
||||
|
||||
与只读同步的关键区别:这里的每个动作都会改变云端真实资源——关机会中断线上服务、
|
||||
续费会真实扣款。因此三条硬约束:
|
||||
1. 路由层强制 require_api_key(外部流量无 key 一律 401)
|
||||
2. 每次动作都写 EventLog(source="action"),成功与失败都记,且用独立 Session 提交,
|
||||
避免主流程回滚时把审计记录一起丢掉
|
||||
3. 适配器不支持该动作时如实抛 400,绝不静默 no-op 让调用方以为成功了
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.adapters import registry
|
||||
from app.adapters.base import BaseAdapter, cloud_error_text
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import Asset, AssetType, DomainDetail
|
||||
from app.services.sync_service import _build_adapter_for_account, _missing_config
|
||||
|
||||
logger = logging.getLogger("vps-manager.action")
|
||||
|
||||
#: 动作名 -> 适配器方法名
|
||||
INSTANCE_ACTIONS = {
|
||||
"start": "start_instance",
|
||||
"stop": "stop_instance",
|
||||
"reboot": "reboot_instance",
|
||||
"renew": "renew_instance",
|
||||
}
|
||||
|
||||
#: 各动作的中文标签,用于审计日志与前端提示
|
||||
ACTION_LABELS = {
|
||||
"start": "开机",
|
||||
"stop": "关机",
|
||||
"reboot": "重启",
|
||||
"renew": "续费",
|
||||
}
|
||||
|
||||
|
||||
def audit(asset_id: int | None, message: str, level: str = "info") -> None:
|
||||
"""写审计日志(独立 Session,主流程回滚也不丢)
|
||||
|
||||
审计失败绝不能让业务动作失败——云端指令可能已经下发成功,
|
||||
此时抛错会让调用方误判为「没执行」,比丢一条日志危险得多。
|
||||
"""
|
||||
from app.database import metrics_engine
|
||||
from app.models.monitor import EventLog
|
||||
|
||||
try:
|
||||
with Session(metrics_engine) as s:
|
||||
s.add(EventLog(level=level, source="action", asset_id=asset_id, message=message))
|
||||
s.commit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("审计日志写入失败 asset_id=%s err=%s", asset_id, e)
|
||||
|
||||
|
||||
def _resolve(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str]:
|
||||
"""解析资产对应的适配器,返回 (asset, adapter, provider_label)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
if not asset.account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该资产未关联账号,无法确定使用哪套云凭证执行动作",
|
||||
)
|
||||
from app.models.asset import Account
|
||||
|
||||
account = session.get(Account, asset.account_id)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="资产关联的账号已不存在"
|
||||
)
|
||||
provider, adapter = _build_adapter_for_account(session, account)
|
||||
missing = _missing_config(adapter)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
|
||||
)
|
||||
return asset, adapter, provider.slug
|
||||
|
||||
|
||||
def _require_capability(adapter: BaseAdapter, method_name: str, label: str) -> None:
|
||||
if not adapter.capabilities().get(method_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{type(adapter).__name__} 不支持{label}",
|
||||
)
|
||||
|
||||
|
||||
def perform_instance_action(
|
||||
session: Session, asset_id: int, action: str, period_months: int = 1
|
||||
) -> dict:
|
||||
"""对 VPS 资产执行 开机/关机/重启/续费"""
|
||||
if action not in INSTANCE_ACTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"未知动作:{action}(可选 {'/'.join(INSTANCE_ACTIONS)})",
|
||||
)
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
if asset.asset_type != AssetType.VPS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"仅 VPS 资产支持该动作(当前类型:{asset.asset_type.value})",
|
||||
)
|
||||
if not asset.external_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该资产没有云端实例 ID(external_id),可能是手工登记的,无法下发指令",
|
||||
)
|
||||
|
||||
method_name = INSTANCE_ACTIONS[action]
|
||||
label = ACTION_LABELS[action]
|
||||
_require_capability(adapter, method_name, label)
|
||||
|
||||
target = f"{provider_slug}/{asset.name}({asset.external_id})"
|
||||
audit(asset_id, f"{label}动作下发:{target}" + (f" 周期={period_months}月" if action == "renew" else ""))
|
||||
try:
|
||||
method = getattr(adapter, method_name)
|
||||
result = method(asset.external_id, period_months) if action == "renew" else method(asset.external_id)
|
||||
except NotImplementedError:
|
||||
audit(asset_id, f"{label}失败:适配器未实现 {target}", level="warning")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
msg = cloud_error_text(e)
|
||||
audit(asset_id, f"{label}异常:{target} err={msg}", level="error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}"
|
||||
) from e
|
||||
|
||||
ok = bool(result.get("ok"))
|
||||
audit(
|
||||
asset_id,
|
||||
f"{label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}",
|
||||
level="info" if ok else "error",
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"action": action,
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"external_id": asset.external_id,
|
||||
"message": result.get("message", ""),
|
||||
"at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_zone(session: Session, asset_id: int) -> tuple[Asset, BaseAdapter, str, str]:
|
||||
"""解析域名资产的 DNS zone 标识,返回 (asset, adapter, provider_slug, zone_id)
|
||||
|
||||
zone_id 取 Asset.external_id:Cloudflare 是 zone uuid,阿里云/腾讯云/Vultr/DO 是域名字符串,
|
||||
两者的 list_dns_records 都已按各自约定实现。
|
||||
"""
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
if asset.asset_type != AssetType.DOMAIN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"仅域名资产支持 DNS 记录管理(当前类型:{asset.asset_type.value})",
|
||||
)
|
||||
zone_id = asset.external_id
|
||||
if not zone_id:
|
||||
detail = session.exec(
|
||||
select(DomainDetail).where(DomainDetail.asset_id == asset_id)
|
||||
).first()
|
||||
zone_id = (detail.domain_name if detail else None) or asset.name
|
||||
if not zone_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无法确定该域名的 zone 标识(external_id 与 domain_name 均为空)",
|
||||
)
|
||||
return asset, adapter, provider_slug, zone_id
|
||||
|
||||
|
||||
def list_dns_records(session: Session, asset_id: int) -> dict:
|
||||
asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id)
|
||||
_require_capability(adapter, "list_dns_records", "DNS 记录查询")
|
||||
try:
|
||||
records = [r.to_dict() for r in adapter.list_dns_records(zone_id)]
|
||||
except NotImplementedError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持 DNS 记录查询"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询 DNS 记录失败:{cloud_error_text(e)}"
|
||||
) from e
|
||||
return {"asset_id": asset_id, "provider": provider_slug, "zone_id": zone_id, "records": records}
|
||||
|
||||
|
||||
def _dns_write(
|
||||
session: Session,
|
||||
asset_id: int,
|
||||
method_name: str,
|
||||
label: str,
|
||||
args: tuple,
|
||||
) -> dict:
|
||||
asset, adapter, provider_slug, zone_id = _resolve_zone(session, asset_id)
|
||||
_require_capability(adapter, method_name, label)
|
||||
target = f"{provider_slug}/{zone_id}"
|
||||
audit(asset_id, f"DNS {label}下发:{target} args={args[1:]}")
|
||||
try:
|
||||
result = getattr(adapter, method_name)(*args)
|
||||
except NotImplementedError:
|
||||
audit(asset_id, f"DNS {label}失败:适配器未实现 {target}", level="warning")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"{provider_slug} 暂不支持{label}"
|
||||
) from None
|
||||
except Exception as e: # noqa: BLE001
|
||||
msg = cloud_error_text(e)
|
||||
audit(asset_id, f"DNS {label}异常:{target} err={msg}", level="error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"{label}失败:{msg}"
|
||||
) from e
|
||||
ok = bool(result.get("ok"))
|
||||
audit(
|
||||
asset_id,
|
||||
f"DNS {label}{'成功' if ok else '被拒'}:{target} msg={result.get('message')}",
|
||||
level="info" if ok else "error",
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"zone_id": zone_id,
|
||||
"record_id": result.get("record_id"),
|
||||
"message": result.get("message", ""),
|
||||
}
|
||||
|
||||
|
||||
def create_dns_record(session: Session, asset_id: int, record: dict) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(session, asset_id, "create_dns_record", "记录创建", (zone_id, record))
|
||||
|
||||
|
||||
def update_dns_record(session: Session, asset_id: int, record_id: str, record: dict) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(
|
||||
session, asset_id, "update_dns_record", "记录更新", (zone_id, record_id, record)
|
||||
)
|
||||
|
||||
|
||||
def delete_dns_record(session: Session, asset_id: int, record_id: str) -> dict:
|
||||
_, _, _, zone_id = _resolve_zone(session, asset_id)
|
||||
return _dns_write(
|
||||
session, asset_id, "delete_dns_record", "记录删除", (zone_id, record_id)
|
||||
)
|
||||
|
||||
|
||||
def asset_actions(session: Session, asset_id: int) -> dict:
|
||||
"""返回某资产当前可执行的动作(前端据此渲染/禁用按钮)
|
||||
|
||||
手工登记、无 external_id、或适配器不支持的,一律标 False,
|
||||
避免用户点了才收到 400。
|
||||
"""
|
||||
try:
|
||||
asset, adapter, provider_slug = _resolve(session, asset_id)
|
||||
except HTTPException as e:
|
||||
return {"asset_id": asset_id, "available": False, "reason": e.detail, "actions": {}}
|
||||
|
||||
caps = adapter.capabilities()
|
||||
is_vps = asset.asset_type == AssetType.VPS
|
||||
is_domain = asset.asset_type == AssetType.DOMAIN
|
||||
has_external = bool(asset.external_id)
|
||||
actions = {
|
||||
name: bool(is_vps and has_external and caps.get(method))
|
||||
for name, method in INSTANCE_ACTIONS.items()
|
||||
}
|
||||
actions["dns_records"] = bool(is_domain and caps.get("list_dns_records"))
|
||||
actions["dns_write"] = bool(is_domain and caps.get("create_dns_record"))
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"provider": provider_slug,
|
||||
"sdk_type": getattr(type(adapter), "sdk_types", []),
|
||||
"available": any(actions.values()),
|
||||
"actions": actions,
|
||||
}
|
||||
@@ -377,11 +377,15 @@ def import_assets(session: Session, assets_data: list) -> dict:
|
||||
created = 0
|
||||
updated = 0
|
||||
errors = []
|
||||
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
|
||||
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name", "account_name"}
|
||||
try:
|
||||
for item in assets_data:
|
||||
try:
|
||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||
# external_id 是 sync_service 的写入字段,AssetCreate/AssetUpdate 不收,
|
||||
# 留在 payload 里会被 pydantic 静默丢弃;导出既然带了它(迁移用),
|
||||
# 这里就必须落到行上,否则迁移后首次同步会把所有云端资产当新增再建一份。
|
||||
external_id = payload.pop("external_id", None)
|
||||
existing = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.name == payload.get("name"),
|
||||
@@ -390,11 +394,14 @@ def import_assets(session: Session, assets_data: list) -> dict:
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
_update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
|
||||
asset = _update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
|
||||
updated += 1
|
||||
else:
|
||||
_create_asset_no_commit(session, AssetCreate(**payload))
|
||||
asset = _create_asset_no_commit(session, AssetCreate(**payload))
|
||||
created += 1
|
||||
if external_id and not asset.external_id:
|
||||
asset.external_id = external_id
|
||||
session.add(asset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{item.get('name', '?')}: {e}")
|
||||
if errors:
|
||||
@@ -462,6 +469,9 @@ def get_overview(session: Session) -> dict:
|
||||
overdue = 0
|
||||
year_cost = 0.0
|
||||
month_cost = 0.0
|
||||
# 同窗口内没填费用的资产数。合计只加得到填了的那部分,缺一项就少算一项,
|
||||
# 界面必须把这个缺口说出来,否则「本年支出」看着像个准数、实际是下限。
|
||||
year_cost_missing = 0
|
||||
|
||||
for asset_type, status_val, expiry_date, cost in rows:
|
||||
tval = asset_type.value if hasattr(asset_type, "value") else asset_type
|
||||
@@ -480,7 +490,10 @@ def get_overview(session: Session) -> dict:
|
||||
elif days <= 30:
|
||||
expiring_30 += 1
|
||||
if expiry_date.year == today.year:
|
||||
year_cost += cost
|
||||
if cost:
|
||||
year_cost += cost
|
||||
else:
|
||||
year_cost_missing += 1
|
||||
if expiry_date.month == today.month:
|
||||
month_cost += cost
|
||||
|
||||
@@ -493,6 +506,7 @@ def get_overview(session: Session) -> dict:
|
||||
"overdue_count": overdue,
|
||||
"year_cost": round(year_cost, 2),
|
||||
"month_cost": round(month_cost, 2),
|
||||
"year_cost_missing": year_cost_missing,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from sqlmodel import Session, select
|
||||
logger = logging.getLogger("vps-manager.sync")
|
||||
|
||||
from app.adapters import registry
|
||||
from app.adapters.base import BaseAdapter, parse_iso_date
|
||||
from app.adapters.base import BaseAdapter, cloud_error_text, parse_iso_date
|
||||
from app.core import crypto
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import (
|
||||
@@ -161,6 +161,9 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
|
||||
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||||
created = updated = 0
|
||||
for vps in adapter.list_vps():
|
||||
# 预付费实例(阿里云/腾讯云包年包月)带到期日,后付费平台为 None。
|
||||
# 漏了这一步,云端同步进来的 VPS 永远进不了续费提醒——而那是台账的核心用途。
|
||||
expiry = parse_iso_date(vps.expiry_date)
|
||||
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
|
||||
if existing:
|
||||
existing.name = vps.name or existing.name
|
||||
@@ -168,6 +171,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
|
||||
if vps.monthly_cost is not None:
|
||||
existing.cost = vps.monthly_cost
|
||||
existing.currency = vps.currency
|
||||
if expiry:
|
||||
existing.expiry_date = expiry
|
||||
if account_id:
|
||||
existing.account_id = account_id
|
||||
session.add(existing)
|
||||
@@ -194,6 +199,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
|
||||
status=_norm_status(vps.status),
|
||||
cost=vps.monthly_cost or 0,
|
||||
currency=vps.currency,
|
||||
expiry_date=expiry,
|
||||
)
|
||||
session.add(asset)
|
||||
session.flush() # 获取 asset.id,统一在循环外提交
|
||||
@@ -292,18 +298,18 @@ def sync_account(session: Session, account_id: int) -> dict:
|
||||
# 写库失败会让 Session 进入 pending-rollback 状态,必须先回滚才能继续,
|
||||
# 否则下面更新 last_synced_at 的 commit 会抛 PendingRollbackError → 接口 500
|
||||
session.rollback()
|
||||
result["vps_error"] = str(e)
|
||||
result["vps_error"] = cloud_error_text(e)
|
||||
if caps["list_domains"]:
|
||||
try:
|
||||
result["domains"] = _sync_domains(session, provider, adapter, account.id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
session.rollback()
|
||||
result["domains_error"] = str(e)
|
||||
result["domains_error"] = cloud_error_text(e)
|
||||
if caps["get_account"]:
|
||||
try:
|
||||
result["account_info"] = adapter.get_account().to_dict()
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["account_info_error"] = str(e)
|
||||
result["account_info_error"] = cloud_error_text(e)
|
||||
|
||||
account.last_synced_at = utcnow()
|
||||
session.add(account)
|
||||
@@ -380,7 +386,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{e}"
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"查询余额失败:{cloud_error_text(e)}"
|
||||
) from e
|
||||
|
||||
if acc.balance is not None:
|
||||
@@ -436,7 +442,7 @@ def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
|
||||
errors.append(f"{name}: {e.detail}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
errors.append(f"{name}: {e}")
|
||||
errors.append(f"{name}: {cloud_error_text(e)}")
|
||||
return {
|
||||
"total": len(asset_ids),
|
||||
"success": success,
|
||||
@@ -445,3 +451,86 @@ def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
|
||||
"errors": errors,
|
||||
"synced_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
"""并发同步所有已配凭证账号的云端资产(VPS / 域名)
|
||||
|
||||
与 sync_all_ai_balances 同构:单个账号失败不中断整体,每个 worker 用独立
|
||||
Session(SQLite Session 不能跨线程共享,WAL 模式下多连接读写安全)。
|
||||
|
||||
未配凭证或平台无适配器的账号计入 skipped 而非 failed——否则每次定时同步
|
||||
都会把手工登记的账号报成一堆错误。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from app.database import assets_engine
|
||||
|
||||
accounts = session.exec(select(Account)).all()
|
||||
targets = []
|
||||
skipped_no_config = []
|
||||
skipped_no_adapter = []
|
||||
for acc in accounts:
|
||||
label = f"{acc.platform}/{acc.name}"
|
||||
if not acc.api_config_encrypted:
|
||||
skipped_no_config.append(label)
|
||||
continue
|
||||
try:
|
||||
provider = _find_provider_by_platform(session, acc.platform)
|
||||
except HTTPException:
|
||||
skipped_no_adapter.append(f"{label}(平台不存在)")
|
||||
continue
|
||||
if not provider.sdk_type or not registry.is_supported(provider.sdk_type):
|
||||
skipped_no_adapter.append(f"{label}({provider.sdk_type or '未配 sdk_type'})")
|
||||
continue
|
||||
targets.append((acc.id, label))
|
||||
|
||||
def _sync_one(account_id: int) -> dict:
|
||||
with Session(assets_engine) as worker_session:
|
||||
return sync_account(worker_session, account_id)
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
created = updated = 0
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(_sync_one, aid): label for aid, label in targets}
|
||||
for future in futures:
|
||||
label = futures[future]
|
||||
try:
|
||||
result = future.result()
|
||||
created += (result.get("vps", {}) or {}).get("created", 0)
|
||||
created += (result.get("domains", {}) or {}).get("created", 0)
|
||||
updated += (result.get("vps", {}) or {}).get("updated", 0)
|
||||
updated += (result.get("domains", {}) or {}).get("updated", 0)
|
||||
# 局部失败(某一类资产同步抛错)不整体算失败,但必须上报
|
||||
sub_error_keys = [
|
||||
k for k in ("vps_error", "domains_error", "account_info_error") if result.get(k)
|
||||
]
|
||||
for key in sub_error_keys:
|
||||
errors.append(f"{label} {key}: {result[key]}")
|
||||
# 但「每一项尝试都失败」不能算成功:凭证过期时三类全 401,
|
||||
# 若仍计成功,汇总行会显示「成功 1」,用户以为拉到了数据、实际一条没有。
|
||||
attempted_any = any(result.get(k) is not None for k in ("vps", "domains", "account_info"))
|
||||
if sub_error_keys and not attempted_any:
|
||||
failed += 1
|
||||
else:
|
||||
success += 1
|
||||
except HTTPException as e:
|
||||
failed += 1
|
||||
errors.append(f"{label}: {e.detail}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
errors.append(f"{label}: {cloud_error_text(e)}")
|
||||
|
||||
return {
|
||||
"total": len(targets),
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"skipped_no_config": len(skipped_no_config),
|
||||
"skipped_no_adapter": len(skipped_no_adapter),
|
||||
"errors": errors,
|
||||
"synced_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user