"""阿里云适配器(含国际版,OpenAPI RPC 风格 + HMAC-SHA1 签名) 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 签名对所有产品线一致,只有 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 import hashlib import hmac import time import uuid from urllib.parse import quote import httpx 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"] def _region(self) -> str: return self.config.get("region") or "cn-hangzhou" def _ecs_endpoint(self) -> str: return f"https://ecs.{self._region()}.aliyuncs.com" @staticmethod def _percent_encode(value) -> str: return quote(str(value), safe="~") def _sign(self, params: dict, method: str = "GET") -> str: canonical = "&".join( f"{self._percent_encode(k)}={self._percent_encode(v)}" for k, v in sorted(params.items()) ) string_to_sign = f"{method}&{self._percent_encode('/')}&{self._percent_encode(canonical)}" key = (self.config.get("access_key_secret", "") + "&").encode() 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, endpoint: str = None, version: str = ECS_VERSION, with_region: bool = True, ) -> dict: """调用任一阿里云 RPC 产品;endpoint 缺省为 ECS 区域端点""" params = { "Format": "JSON", "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, } # BSS / Domain / Alidns 是中心化服务,传 RegionId 会被拒或忽略 if with_region: params["RegionId"] = self._region() if 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(endpoint or self._ecs_endpoint(), params=params) resp.raise_for_status() data = resp.json() if data.get("Code"): raise RuntimeError(f"{data.get('Code')}: {data.get('Message')}") return data def test_connection(self) -> dict: try: data = self._call("DescribeRegions") count = len(data.get("Regions", {}).get("Region", [])) 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 = [] page = 1 while True: data = self._call("DescribeInstances", {"PageSize": "100", "PageNumber": str(page)}) instances = data.get("Instances", {}).get("Instance", []) for inst in instances: public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", []) eip = inst.get("EipAddress", {}).get("IpAddress") mem_mb = inst.get("Memory") or 0 charge = inst.get("InstanceChargeType") result.append( NormalizedVPS( external_id=inst.get("InstanceId"), name=inst.get("InstanceName") or inst.get("InstanceId"), ip_address=eip or (public_ips[0] if public_ips else None), region=inst.get("RegionId"), 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"), 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) if not instances or page * 100 >= total: 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}"}