FastAPI-based SSL certificate automation server. - Google Public CA wildcard cert issuance via certbot - Cloudflare DNS-01 challenge with auto EAB key generation - APISIX multi-instance deployment with domain-instance mapping - Vault integration for all secrets - Bearer token auth, retry logic, Discord DM alerts - Auto-renewal scheduler (daily 03:00 UTC) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
239 lines
7.8 KiB
Python
239 lines
7.8 KiB
Python
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from fastapi import FastAPI, HTTPException, Request, Security
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from pydantic import BaseModel
|
|
|
|
from . import apisix, certbot, cloudflare
|
|
from .alert import send_discord_dm
|
|
from .config import AppConfig, load_config, validate_domain
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
config: AppConfig = None # type: ignore
|
|
scheduler = AsyncIOScheduler()
|
|
security = HTTPBearer()
|
|
|
|
|
|
# --- Auth ---
|
|
|
|
async def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)) -> str:
|
|
if not config.api_token:
|
|
raise HTTPException(status_code=500, detail="API token not configured")
|
|
if credentials.credentials != config.api_token:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
return credentials.credentials
|
|
|
|
|
|
# --- Request/Response models ---
|
|
|
|
class DomainRequest(BaseModel):
|
|
domain: str
|
|
instances: list[str] | None = None
|
|
|
|
|
|
class SyncResponse(BaseModel):
|
|
results: list[dict]
|
|
|
|
|
|
# --- Alert helper ---
|
|
|
|
async def _alert(message: str):
|
|
"""Discord DM으로 알림. 실패해도 무시."""
|
|
try:
|
|
await send_discord_dm(config.discord_bot_token, config.discord_alert_user_id, message)
|
|
except Exception as e:
|
|
logger.warning("Alert send failed: %s", e)
|
|
|
|
|
|
# --- Scheduled task ---
|
|
|
|
async def auto_renew():
|
|
"""만료 30일 이내 인증서 자동 갱신 + APISIX 재배포."""
|
|
global config
|
|
# 설정 리로드 (Vault 토큰 만료 대응)
|
|
try:
|
|
config = load_config()
|
|
except Exception as e:
|
|
logger.error("Config reload failed: %s", e)
|
|
await _alert(f"[cert-manager] Config reload failed: {e}")
|
|
return
|
|
|
|
logger.info("Starting auto-renewal check")
|
|
certs = certbot.list_certificates(config)
|
|
now = datetime.now(timezone.utc)
|
|
failures = []
|
|
|
|
for cert in certs:
|
|
if cert["days_remaining"] <= 30:
|
|
domain = cert["domain"]
|
|
logger.info("Renewing %s (expires in %d days)", domain, cert["days_remaining"])
|
|
result = await certbot.issue_certificate(domain, config)
|
|
if result["success"]:
|
|
# domain_instance_map에 따라 대상 인스턴스 결정
|
|
target_instances = _resolve_instances(domain)
|
|
await apisix.deploy_certificate(domain, config, instances=target_instances)
|
|
else:
|
|
error = result.get("error", "Unknown")
|
|
logger.error("Renewal failed for %s: %s", domain, error)
|
|
failures.append(f"{domain}: {error}")
|
|
|
|
if failures:
|
|
msg = "[cert-manager] Renewal failures:\n" + "\n".join(failures)
|
|
await _alert(msg)
|
|
|
|
logger.info("Auto-renewal check completed")
|
|
|
|
|
|
def _resolve_instances(domain: str) -> list | None:
|
|
"""domain_instance_map에서 도메인에 맞는 APISIX 인스턴스 목록 반환."""
|
|
if not config.domain_instance_map:
|
|
return None # 매핑 없으면 전체
|
|
|
|
for pattern, instance_names in config.domain_instance_map.items():
|
|
if domain == pattern or domain.endswith(f".{pattern.lstrip('*.')}"):
|
|
matched = [i for i in config.apisix_instances if i.name in instance_names]
|
|
if matched:
|
|
return matched
|
|
return None # 매핑에 없으면 전체
|
|
|
|
|
|
# --- Lifespan ---
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global config
|
|
config = load_config()
|
|
scheduler.add_job(auto_renew, CronTrigger(hour=3, minute=0), id="auto_renew")
|
|
scheduler.start()
|
|
logger.info("Scheduler started")
|
|
yield
|
|
scheduler.shutdown()
|
|
|
|
|
|
app = FastAPI(title="cert-manager", lifespan=lifespan)
|
|
|
|
|
|
# --- Endpoints ---
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()}
|
|
|
|
|
|
@app.get("/domains")
|
|
async def get_domains(token: str = Security(verify_token)):
|
|
try:
|
|
domains = await cloudflare.list_domains(config)
|
|
return {"domains": domains, "count": len(domains)}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=str(e))
|
|
|
|
|
|
@app.get("/certificates")
|
|
async def get_certificates(token: str = Security(verify_token)):
|
|
certs = certbot.list_certificates(config)
|
|
return {"certificates": certs, "count": len(certs)}
|
|
|
|
|
|
@app.get("/certificates/{domain}")
|
|
async def get_certificate_detail(domain: str, token: str = Security(verify_token)):
|
|
"""특정 도메인의 인증서 정보 + PEM 내용 반환."""
|
|
error = validate_domain(domain)
|
|
if error:
|
|
raise HTTPException(status_code=400, detail=error)
|
|
|
|
info = certbot.get_certificate_info(domain, config)
|
|
if not info:
|
|
raise HTTPException(status_code=404, detail=f"Certificate not found for {domain}")
|
|
from pathlib import Path
|
|
cert_pem = Path(info["cert_path"]).read_text()
|
|
key_pem = Path(info["key_path"]).read_text()
|
|
return {
|
|
**info,
|
|
"cert": cert_pem,
|
|
"key": key_pem,
|
|
}
|
|
|
|
|
|
@app.post("/certificates/issue")
|
|
async def issue_certificate(req: DomainRequest, token: str = Security(verify_token)):
|
|
error = validate_domain(req.domain)
|
|
if error:
|
|
raise HTTPException(status_code=400, detail=error)
|
|
|
|
result = await certbot.issue_certificate(req.domain, config)
|
|
if not result["success"]:
|
|
raise HTTPException(status_code=500, detail=result.get("error", "Unknown error"))
|
|
return result
|
|
|
|
|
|
@app.post("/certificates/deploy")
|
|
async def deploy_certificate(req: DomainRequest, token: str = Security(verify_token)):
|
|
error = validate_domain(req.domain)
|
|
if error:
|
|
raise HTTPException(status_code=400, detail=error)
|
|
|
|
targets = None
|
|
if req.instances:
|
|
targets = [i for i in config.apisix_instances if i.name in req.instances]
|
|
if not targets:
|
|
raise HTTPException(status_code=400, detail=f"Unknown instances: {req.instances}")
|
|
results = await apisix.deploy_certificate(req.domain, config, instances=targets)
|
|
failures = [r for r in results if not r["success"]]
|
|
if failures and len(failures) == len(results):
|
|
raise HTTPException(status_code=500, detail="All deployments failed")
|
|
return {"domain": req.domain, "results": results}
|
|
|
|
|
|
async def _sync_one(domain: str) -> dict:
|
|
"""단일 도메인 발급 + 배포."""
|
|
logger.info("Syncing %s", domain)
|
|
issue_result = await certbot.issue_certificate(domain, config)
|
|
if not issue_result["success"]:
|
|
return {"domain": domain, "issue": issue_result, "deploy": None}
|
|
target_instances = _resolve_instances(domain)
|
|
deploy_results = await apisix.deploy_certificate(domain, config, instances=target_instances)
|
|
return {"domain": domain, "issue": issue_result, "deploy": deploy_results}
|
|
|
|
|
|
@app.post("/certificates/sync")
|
|
async def sync_all(token: str = Security(verify_token)):
|
|
"""전체 도메인 조회 → 3개씩 병렬 발급+배포."""
|
|
domains = await cloudflare.list_domains(config)
|
|
sem = asyncio.Semaphore(3)
|
|
|
|
async def _limited(domain: str):
|
|
async with sem:
|
|
return await _sync_one(domain)
|
|
|
|
results = await asyncio.gather(
|
|
*[_limited(z["name"]) for z in domains],
|
|
return_exceptions=True,
|
|
)
|
|
|
|
# 예외를 dict로 변환
|
|
final = []
|
|
failures = []
|
|
for i, r in enumerate(results):
|
|
if isinstance(r, Exception):
|
|
domain = domains[i]["name"]
|
|
final.append({"domain": domain, "error": str(r)})
|
|
failures.append(f"{domain}: {r}")
|
|
else:
|
|
final.append(r)
|
|
|
|
if failures:
|
|
await _alert("[cert-manager] Sync failures:\n" + "\n".join(failures))
|
|
|
|
return {"results": final, "total": len(final)}
|