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:
gouki
2026-10-12 00:43:37 +00:00
parent 192d448a28
commit 46faadbcae
42 changed files with 5570 additions and 99 deletions
+251 -12
View File
@@ -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}"}
+369
View File
@@ -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
View File
@@ -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}
+96 -5
View File
@@ -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}"}
+129 -3
View File
@@ -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}"}
+213
View File
@@ -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}"}
+163
View File
@@ -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}"}
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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}"}