审计发现两处「已声明但实际用不了」的缺口: - 阿里云/腾讯云国际版与国内版共用适配器,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。
363 lines
14 KiB
Python
363 lines
14 KiB
Python
"""腾讯云适配器(含国际版,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}"}
|