"""腾讯云适配器(含国际版,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"] 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}"}