评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
370 lines
15 KiB
Python
370 lines
15 KiB
Python
"""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 变更为异步)"}
|