Files
vps-manager/app/adapters/tencent.py
T
gouki 9e358fed6b fix(adapters): 补齐国际版 region 填写与 NameSilo 余额
审计发现两处「已声明但实际用不了」的缺口:

- 阿里云/腾讯云国际版与国内版共用适配器,region 只能靠代码默认值(cn-hangzhou /
  ap-guangzhou),账号弹窗里无处填写,海外账号查不到资源。新增 optional_config
  机制,region 按可选明文渲染;留空仍走原默认值,不阻塞保存与同步。
- NameSilo 有 getAccountBalance 接口但未实现,平台卡片不显示余额。补充余额
  与未出账消费(available_balance / unbilled_amount 分开报告)。

顺带修掉一个会丢凭证的隐患:编辑账号时前端只提交填写过的字段,后端原先整段
覆盖 api_config,只补 region 会把已存 AK/SK 抹掉;改为与已存 JSON 合并。

测试:449 passed(新增合并、余额、optional_config 用例),vitest 62 passed。
2026-10-26 12:05:48 +00:00

363 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""腾讯云适配器(含国际版,TC3-HMAC-SHA256 签名)
API 文档:https://cloud.tencent.com/document/api/213/15753
所需配置:{"secret_id": "...", "secret_key": "...", "region": "ap-guangzhou"}
国际版(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
import hmac
import json
import time
import httpx
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"]
#: 国际版账号在国内默认区域上会查不到资源,region 必须可填(留空用 ap-guangzhou)
optional_config = ["region"]
plain_config = ["region"]
def _region(self) -> str:
return self.config.get("region") or "ap-guangzhou"
@staticmethod
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, 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:{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}/{service}/tc3_request"
hashed_canonical = hashlib.sha256(canonical_request.encode()).hexdigest()
string_to_sign = (
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, service)
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
signature = hmac.new(secret_signing, string_to_sign.encode(), hashlib.sha256).hexdigest()
return (
f"{ALGORITHM} Credential={secret_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, Signature={signature}"
)
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))
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": host,
"X-TC-Action": action,
"X-TC-Timestamp": str(timestamp),
"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://{host}", content=payload_json, headers=headers)
resp.raise_for_status()
data = resp.json()
response = data.get("Response", {})
if response.get("Error"):
err = response["Error"]
raise RuntimeError(f"{err.get('Code')}: {err.get('Message')}")
return response
def test_connection(self) -> dict:
try:
data = self._call("DescribeRegions")
count = len(data.get("RegionSet", []))
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:
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
result = []
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"),
name=inst.get("InstanceName") or inst.get("InstanceId"),
ip_address=public_ips[0] if public_ips else None,
region=inst.get("Placement", {}).get("Zone"),
os=inst.get("OsName"),
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"),
currency="CNY",
# 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效
expiry_date=parse_iso_date(inst.get("ExpiredTime")),
charge_type=_CHARGE_TYPE_MAP.get(charge, charge),
raw=inst,
)
)
total = int(data.get("TotalCount") or 0)
offset += len(inst_set)
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}"}