"""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 变更为异步)"}