AirGate/backend/utils/volcengine_client.py
seaislee1209 3213d6d98a feat: complete AirGate core features + full audit fixes
Quota allocation system:
- Replace monthly budget with one-time quota allocation (prepaid model)
- Support both adding (+) and deducting (-) quota with underflow protection
- Stepped alerts at configurable percentages (e.g., 50%/80%/90%)
- Auto-disable when quota exhausted (100%), alert state resets on new allocation
- Quota allocation history with operator audit trail

IAM management:
- Create new IAM sub-accounts directly from AirGate (auto-generates API keys)
- SecretKey shown once in dialog with copy-to-clipboard
- Attach/detach IAM policies via UI (ArkFullAccess, TOSFullAccess, etc.)
- Sync existing users from Volcengine
- Project list pulled from Volcengine API for dropdown selection

Security & auth:
- API Key authentication for external systems (AirDrama integration)
- SECRET_KEY enforced in production (raises error if missing with DEBUG=False)
- APIKeyUser with proper pk/is_staff attributes for DRF compatibility

Infrastructure:
- Docker + docker-compose for backend and frontend
- Nginx reverse proxy for frontend with /api/ forwarding
- Entrypoint with auto-migrate and default admin creation
- SQLite data persisted via Docker volume at /app/data/

Bug fixes from audit:
- Fix frontend referencing non-existent fields (current_month_spending, effective_budget, budget_usage_percent)
- Fix scheduler using naive datetime.now() → timezone.now()
- Fix scheduler reading interval from settings instead of GlobalConfig DB
- Fix docker-compose SQLite volume mounting as directory
- Fix CORS origin with explicit port 80
- Remove dead config (VOLC_ACCESS_KEY/SK, MONITOR_INTERVAL from settings)
- Remove unused imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:08:33 +08:00

121 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""火山引擎 Open API 客户端HMAC-SHA256 签名)"""
import datetime
import hashlib
import hmac
import logging
from urllib.parse import quote
import requests
logger = logging.getLogger(__name__)
class VolcengineAPIError(Exception):
def __init__(self, action: str, code: str, message: str):
self.action = action
self.code = code
super().__init__(f"[{action}] {code}: {message}")
class VolcengineClient:
"""火山引擎 API 客户端"""
def __init__(self, ak: str, sk: str, service: str, host: str,
region: str = "cn-north-1", version: str = "2018-01-01"):
self.ak = ak
self.sk = sk
self.service = service
self.host = host
self.region = region
self.version = version
def _norm_query(self, params: dict) -> str:
query = ""
for key in sorted(params.keys()):
if isinstance(params[key], list):
for v in params[key]:
query += quote(key, safe="-_.~") + "=" + quote(str(v), safe="-_.~") + "&"
else:
query += quote(key, safe="-_.~") + "=" + quote(str(params[key]), safe="-_.~") + "&"
return query[:-1].replace("+", "%20") if query else ""
def _hmac_sha256(self, key: bytes, content: str) -> bytes:
return hmac.new(key, content.encode("utf-8"), hashlib.sha256).digest()
def _hash_sha256(self, content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def call(self, action: str, params: dict = None, body: str = "") -> dict:
params = params or {}
now = datetime.datetime.now(datetime.timezone.utc)
x_date = now.strftime("%Y%m%dT%H%M%SZ")
short_date = x_date[:8]
x_content_sha256 = self._hash_sha256(body)
all_params = {"Action": action, "Version": self.version, **params}
signed_headers_str = "content-type;host;x-content-sha256;x-date"
canonical_headers = (
f"content-type:application/x-www-form-urlencoded\n"
f"host:{self.host}\n"
f"x-content-sha256:{x_content_sha256}\n"
f"x-date:{x_date}"
)
query_string = self._norm_query(all_params)
canonical_request = "\n".join([
"GET", "/", query_string,
canonical_headers, "", signed_headers_str, x_content_sha256
])
credential_scope = f"{short_date}/{self.region}/{self.service}/request"
string_to_sign = "\n".join([
"HMAC-SHA256", x_date, credential_scope,
self._hash_sha256(canonical_request)
])
k_date = self._hmac_sha256(self.sk.encode("utf-8"), short_date)
k_region = self._hmac_sha256(k_date, self.region)
k_service = self._hmac_sha256(k_region, self.service)
k_signing = self._hmac_sha256(k_service, "request")
signature = self._hmac_sha256(k_signing, string_to_sign).hex()
headers = {
"Host": self.host,
"X-Date": x_date,
"X-Content-Sha256": x_content_sha256,
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": (
f"HMAC-SHA256 Credential={self.ak}/{credential_scope}, "
f"SignedHeaders={signed_headers_str}, Signature={signature}"
),
}
url = f"https://{self.host}/?{query_string}"
try:
r = requests.get(url, headers=headers, timeout=30)
resp = r.json()
except Exception as e:
raise VolcengineAPIError(action, "NetworkError", str(e))
error = resp.get("ResponseMetadata", {}).get("Error")
if error:
raise VolcengineAPIError(
action, error.get("Code", "Unknown"), error.get("Message", "")
)
return resp
def get_iam_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com")
def get_billing_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "billing", "billing.volcengineapi.com",
version="2022-01-01")
def get_resource_client(ak: str, sk: str) -> VolcengineClient:
return VolcengineClient(ak, sk, "iam", "iam.volcengineapi.com",
version="2021-08-01")