""" AWS 커넥터 — SigV4 직접 서명 (boto3 미사용) 엔드포인트: POST /api/aws/config — AWS 자격증명 설정 GET /api/aws/instances — EC2 인스턴스 목록 GET /api/aws/rds — RDS 인스턴스 목록 GET /api/aws/s3 — S3 버킷 목록 GET /api/aws/costs — 이번 달 비용 POST /api/aws/test — 연결 테스트 """ from __future__ import annotations import hashlib, hmac, json, logging from datetime import datetime, timezone from typing import Optional from urllib.parse import urlencode import httpx from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import User, AWSConfig logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/aws", tags=["AWS 커넥터"]) class AWSConfigCreate(BaseModel): name: str = "AWS 연동" access_key_id: str secret_access_key: str region: str = Field("ap-northeast-2", description="기본 서울 리전") account_id: Optional[str] = None def _sign(key: bytes, msg: str) -> bytes: return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest() def _get_signature_key(secret_key: str, date_stamp: str, region: str, service: str) -> bytes: k_date = _sign(f"AWS4{secret_key}".encode(), date_stamp) k_region = _sign(k_date, region) k_service = _sign(k_region, service) return _sign(k_service, "aws4_request") async def _aws_request(cfg: AWSConfig, service: str, action: str, params: dict = None) -> Optional[dict]: """AWS API 호출 (SigV4 서명).""" params = params or {} params["Action"] = action params["Version"] = "2016-11-15" if service == "ec2" else "2014-10-31" now = datetime.now(timezone.utc) amz_date = now.strftime('%Y%m%dT%H%M%SZ') date_stamp = now.strftime('%Y%m%d') endpoint = f"https://{service}.{cfg.region}.amazonaws.com/" query_string = urlencode(sorted(params.items())) payload_hash = hashlib.sha256(b"").hexdigest() headers = { "host": f"{service}.{cfg.region}.amazonaws.com", "x-amz-date": amz_date, "x-amz-content-sha256": payload_hash, } canonical_headers = "".join(f"{k}:{v}\n" for k, v in sorted(headers.items())) signed_headers = ";".join(sorted(headers.keys())) canonical_request = f"GET\n/\n{query_string}\n{canonical_headers}\n{signed_headers}\n{payload_hash}" credential_scope = f"{date_stamp}/{cfg.region}/{service}/aws4_request" string_to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{hashlib.sha256(canonical_request.encode()).hexdigest()}" signing_key = _get_signature_key(cfg.secret_key_enc, date_stamp, cfg.region, service) signature = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest() auth = (f"AWS4-HMAC-SHA256 Credential={cfg.access_key_id}/{credential_scope}," f"SignedHeaders={signed_headers},Signature={signature}") headers["Authorization"] = auth headers.pop("host") try: async with httpx.AsyncClient(timeout=15) as client: r = await client.get(f"{endpoint}?{query_string}", headers=headers) if r.status_code == 200: import xml.etree.ElementTree as ET root = ET.fromstring(r.text) return {"raw_xml": r.text[:2000], "status": r.status_code} return {"error": r.text[:200], "status": r.status_code} except Exception as e: logger.error(f"AWS API 실패: {e}") return None @router.post("/config") async def save_aws_config(req: AWSConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)): row = await db.execute(select(AWSConfig).where(AWSConfig.tenant_id == user.tenant_id)) cfg = row.scalar_one_or_none() if cfg: cfg.access_key_id = req.access_key_id cfg.secret_key_enc = req.secret_access_key cfg.region = req.region else: cfg = AWSConfig(tenant_id=user.tenant_id, name=req.name, access_key_id=req.access_key_id, secret_key_enc=req.secret_access_key, region=req.region, is_active=True, created_at=datetime.utcnow()) db.add(cfg) await db.commit() return {"ok": True} async def _get_cfg(user: User, db: AsyncSession) -> AWSConfig: row = await db.execute(select(AWSConfig).where(AWSConfig.tenant_id == user.tenant_id, AWSConfig.is_active == True)) cfg = row.scalar_one_or_none() if not cfg: raise HTTPException(404, "AWS 설정 없음") return cfg @router.post("/test") async def test_aws(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): cfg = await _get_cfg(user, db) result = await _aws_request(cfg, "ec2", "DescribeRegions") return {"ok": result is not None and result.get("status") == 200, "region": cfg.region} @router.get("/instances") async def list_ec2_instances(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): cfg = await _get_cfg(user, db) result = await _aws_request(cfg, "ec2", "DescribeInstances") return {"provider": "AWS", "region": cfg.region, "raw": result, "note": "XML 파싱 완료 버전은 추후 제공"} @router.get("/costs") async def get_aws_costs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): cfg = await _get_cfg(user, db) return {"provider": "AWS", "region": cfg.region, "note": "Cost Explorer API는 us-east-1에서만 조회 가능 — 별도 설정 필요"}