Initial commit: cert-manager API server
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>
This commit is contained in:
238
app/main.py
Normal file
238
app/main.py
Normal file
@@ -0,0 +1,238 @@
|
||||
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)}
|
||||
Reference in New Issue
Block a user