""" SSL 인증서 관리 API. 엔드포인트: GET /api/ssl/expiring — 만료 임박 서버 목록 POST /api/ssl/check/{server_id} — SSH로 실제 인증서 확인 및 DB 갱신 POST /api/ssl/renew/{server_id} — 갱신 완료 기록 (SslHistory 생성) GET /api/ssl/history/{server_id} — SSL 갱신 이력 조회 GET /api/ssl/summary — 전체 현황 통계 보안: ip_addr, ssh_user, os_pw_enc 응답 제외 / SSH 결과에서 민감정보 제거. """ from __future__ import annotations import json import logging from datetime import date, datetime, timedelta from typing import List, Optional from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import ( Institution, Server, SslAlertLevel, SslExpiryInfo, SslHistory, SslHistoryCreate, SslHistoryOut, User, UserRole, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/ssl", tags=["ssl"]) # ── 공통 헬퍼 ───────────────────────────────────────────────────────────────── def _compute_alert(expire: Optional[date]) -> tuple[Optional[int], SslAlertLevel]: if expire is None: return None, SslAlertLevel.OK today = date.today() days_left = (expire - today).days if days_left < 0: return days_left, SslAlertLevel.EXPIRED if days_left <= 7: return days_left, SslAlertLevel.URGENT if days_left <= 30: return days_left, SslAlertLevel.WARN return days_left, SslAlertLevel.OK async def _get_server_or_404(server_id: int, db: AsyncSession) -> Server: r = await db.execute(select(Server).where(Server.id == server_id)) srv = r.scalars().first() if not srv: raise HTTPException(404, "서버를 찾을 수 없습니다.") return srv # ── 만료 임박 목록 ──────────────────────────────────────────────────────────── @router.get("/expiring", response_model=List[SslExpiryInfo]) async def list_expiring( days: int = Query(30, ge=0, le=365, description="N일 이내 만료 서버 조회"), inst_id: Optional[int] = Query(None), include_ok: bool = Query(False, description="정상 서버 포함 여부"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """SSL 만료 임박 서버 목록 (days 파라미터 이내 만료 또는 이미 만료된 서버).""" q = select(Server).where(Server.ssl_expire_date.isnot(None)) if inst_id: q = q.where(Server.inst_id == inst_id) # CUSTOMER 역할: 자기 기관 서버만 if current_user.role == UserRole.CUSTOMER and current_user.inst_code: r_inst = await db.execute( select(Institution).where(Institution.inst_code == current_user.inst_code) ) own_inst = r_inst.scalars().first() if own_inst: q = q.where(Server.inst_id == own_inst.id) result = await db.execute(q.order_by(Server.ssl_expire_date)) servers = result.scalars().all() # 기관명 매핑 inst_ids = {s.inst_id for s in servers if s.inst_id} inst_map: dict[int, str] = {} if inst_ids: ir = await db.execute(select(Institution).where(Institution.id.in_(inst_ids))) for inst in ir.scalars(): inst_map[inst.id] = inst.inst_name items: list[SslExpiryInfo] = [] threshold = date.today() + timedelta(days=days) for srv in servers: days_left, level = _compute_alert(srv.ssl_expire_date) if not include_ok and level == SslAlertLevel.OK: continue if srv.ssl_expire_date and srv.ssl_expire_date > threshold and level == SslAlertLevel.OK: continue items.append(SslExpiryInfo( server_id = srv.id, server_name = srv.server_name, inst_name = inst_map.get(srv.inst_id) if srv.inst_id else None, ssl_cert_path = srv.ssl_cert_path, ssl_expire_date = srv.ssl_expire_date, days_left = days_left, alert_level = level, )) return items # ── SSH 실시간 인증서 확인 ──────────────────────────────────────────────────── @router.post("/check/{server_id}") async def check_ssl_via_ssh( server_id: int, host_override: Optional[str] = Query(None, description="점검 대상 호스트 (미입력 시 서버 IP)"), port: int = Query(443, description="HTTPS 포트"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ SSH로 서버에 접속하여 ssl_expiry_check.sh 실행 → DB 업데이트. 반환: {"days_left": N, "alert_level": "...", "expire_date": "YYYY-MM-DD", "updated": bool} """ if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "권한이 없습니다.") srv = await _get_server_or_404(server_id, db) try: from core.ssh_exec import run_command_on_server except ImportError: raise HTTPException(500, "SSH 실행 모듈을 불러올 수 없습니다.") target_host = host_override or srv.server_name cmd = f"bash /opt/guardia/scripts/ssl/ssl_expiry_check.sh {target_host} {port}" try: output = await run_command_on_server(srv, cmd) except Exception as exc: logger.error("SSL 점검 SSH 실패: server=%s err=%s", srv.server_name, exc) raise HTTPException(500, "SSH 접속에 실패했습니다. SR을 접수해 주세요.") # JSON 파싱 data: dict = {} for line in (output or "").splitlines(): line = line.strip() if line.startswith("{") and line.endswith("}"): try: data = json.loads(line) break except json.JSONDecodeError: pass if not data: raise HTTPException(502, "SSL 점검 스크립트 출력을 파싱할 수 없습니다.") if data.get("status") == "ERROR": raise HTTPException(502, f"SSL 점검 실패: {data.get('message', '알 수 없는 오류')}") # 만료일 파싱 (openssl 출력: "Oct 15 12:00:00 2026 GMT") expire_str = data.get("expiry", "") new_expire: Optional[date] = None if expire_str: for fmt in ("%b %d %H:%M:%S %Y %Z", "%b %d %H:%M:%S %Y %Z"): try: new_expire = datetime.strptime(expire_str.strip(), fmt).date() break except ValueError: pass # DB 업데이트 updated = False if new_expire: async with db.begin_nested(): srv.ssl_expire_date = new_expire await db.commit() updated = True logger.info( "SSL DB 갱신: server=%s expire=%s", srv.server_name, new_expire.isoformat(), ) days_left_raw = data.get("days_left") days_left = int(days_left_raw) if days_left_raw is not None else None level_str = data.get("level", "OK") level_map = {"OK": SslAlertLevel.OK, "WARN": SslAlertLevel.WARN, "CRITICAL": SslAlertLevel.URGENT} alert_level = level_map.get(level_str, SslAlertLevel.OK) return { "server_id": server_id, "server_name": srv.server_name, "days_left": days_left, "expire_date": new_expire.isoformat() if new_expire else None, "alert_level": alert_level, "updated": updated, } # ── 갱신 완료 기록 ──────────────────────────────────────────────────────────── @router.post("/renew/{server_id}", response_model=SslHistoryOut, status_code=201) async def record_ssl_renewal( server_id: int, payload: SslHistoryCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ SSL 인증서 갱신 완료 기록. - SslHistory 생성 - Server.ssl_expire_date 업데이트 - payload.sr_id 없으면 자동 SR 생성 (선택) """ if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "권한이 없습니다.") if payload.server_id != server_id: raise HTTPException(422, "URL server_id와 payload server_id가 다릅니다.") srv = await _get_server_or_404(server_id, db) # 이전 만료일 저장 old_expire = srv.ssl_expire_date # SslHistory 생성 history = SslHistory( server_id = server_id, cert_domain = payload.cert_domain, old_expire = old_expire or payload.old_expire, new_expire = payload.new_expire, issuer = payload.issuer, cert_path = payload.cert_path, renewed_by = payload.renewed_by or current_user.username, sr_id = payload.sr_id, note = payload.note, ) db.add(history) # Server 만료일 업데이트 if payload.new_expire: srv.ssl_expire_date = payload.new_expire if payload.cert_path: srv.ssl_cert_path = payload.cert_path await db.commit() await db.refresh(history) logger.info( "SSL 갱신 기록: server=%s old=%s new=%s renewed_by=%s", srv.server_name, old_expire.isoformat() if old_expire else "-", payload.new_expire.isoformat() if payload.new_expire else "-", current_user.username, ) return history # ── SSL 갱신 이력 ───────────────────────────────────────────────────────────── @router.get("/history/{server_id}", response_model=List[SslHistoryOut]) async def get_ssl_history( server_id: int, skip: int = 0, limit: int = 50, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """서버별 SSL 인증서 갱신 이력.""" # 서버 존재 확인 await _get_server_or_404(server_id, db) result = await db.execute( select(SslHistory) .where(SslHistory.server_id == server_id) .order_by(SslHistory.created_at.desc()) .offset(skip).limit(limit) ) return result.scalars().all() # ── 현황 통계 ───────────────────────────────────────────────────────────────── @router.get("/summary") async def ssl_summary( db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """전체 SSL 현황 통계 (등급별 서버 수).""" result = await db.execute( select(Server).where(Server.ssl_expire_date.isnot(None)) ) servers = result.scalars().all() counts = {lv.value: 0 for lv in SslAlertLevel} no_ssl = 0 urgent_servers: list[dict] = [] # ssl_expire_date 없는 서버 수 r2 = await db.execute(select(Server).where(Server.ssl_expire_date.is_(None))) no_ssl = len(r2.scalars().all()) for srv in servers: days_left, level = _compute_alert(srv.ssl_expire_date) counts[level.value] += 1 if level in (SslAlertLevel.URGENT, SslAlertLevel.EXPIRED): urgent_servers.append({ "server_id": srv.id, "server_name": srv.server_name, "expire_date": srv.ssl_expire_date.isoformat() if srv.ssl_expire_date else None, "days_left": days_left, "alert_level": level.value, }) return { "total_monitored": len(servers), "no_ssl_date": no_ssl, "by_level": counts, "urgent_servers": sorted(urgent_servers, key=lambda x: x.get("days_left", 9999)), } # ── certbot 자동 갱신 콜백 ──────────────────────────────────────────────────── class SslRenewCallbackPayload(BaseModel): """ssl_auto_renew.sh 에서 POST 하는 콜백 페이로드.""" event: str # "ssl_renew" 고정 status: str # SUCCESS | FAILURE | DRY_RUN_OK | DRY_RUN_FAIL message: str renewed_domains: Optional[str] = "" web_server: Optional[str] = "" renew_days: Optional[int] = 30 exit_code: Optional[int] = 0 log_file: Optional[str] = "" timestamp: Optional[str] = "" @router.post("/renew-callback", status_code=200) async def ssl_renew_callback( payload: SslRenewCallbackPayload, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """ certbot 자동 갱신 스크립트(ssl_auto_renew.sh)에서 호출하는 콜백. 갱신 성공 시 해당 도메인의 SslHistory 레코드 자동 생성. FAILURE 상태 시 SRRequest 자동 생성 (알림용). 보안: Bearer 토큰 인증 필수 — 미인증 요청은 401 반환. """ logger.info( "SSL 갱신 콜백 수신: status=%s domains=%s exit_code=%s", payload.status, payload.renewed_domains, payload.exit_code, ) # ── 성공: 갱신된 도메인별 SslHistory 생성 ───────────────────────────────── if payload.status == "SUCCESS" and payload.renewed_domains: domains = [d.strip() for d in payload.renewed_domains.split(",") if d.strip()] created_histories: list[int] = [] for domain in domains: # server_name 으로 서버 조회 (매칭 안 되면 서버 없음 처리) srv_row = (await db.execute( select(Server).where(Server.server_name == domain) )).scalars().first() if not srv_row: # 매칭 서버 없음 — 도메인 이름으로 유사 검색 (fqdn 포함 서버명) srv_row = (await db.execute( select(Server).where(Server.server_name.contains( domain.split(".")[0] # 첫 서브도메인으로 부분 매칭 )) )).scalars().first() if not srv_row: logger.warning("SSL 콜백: domain=%s 에 해당하는 서버 없음 — SslHistory 미생성", domain) continue # 이전 만료일 보존 old_expire = srv_row.ssl_expire_date history = SslHistory( server_id = srv_row.id, cert_domain = domain, old_expire = old_expire, new_expire = date.today() + timedelta(days=90), # certbot 기본 90일 note = ( f"ssl_auto_renew.sh 자동 갱신\n" f"web_server={payload.web_server or '-'}\n" f"log={payload.log_file or '-'}" ), renewed_by = "ssl-autorenew-bot", ) db.add(history) await db.flush() created_histories.append(history.id) # 서버 ssl_expire_date 업데이트 (+90일) srv_row.ssl_expire_date = date.today() + timedelta(days=90) await db.commit() logger.info("SslHistory 생성: %d건 (%s)", len(created_histories), domains) return { "ok": True, "status": payload.status, "histories_created": created_histories, "renewed_domains": domains, } # ── 실패: SRRequest 자동 생성 ───────────────────────────────────────────── if payload.status in ("FAILURE", "DRY_RUN_FAIL"): from uuid import uuid4 from models import SRRequest, SRStatus, SRType, Priority sr_id = f"SSL-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}" sr = SRRequest( sr_id = sr_id, sr_type = SRType.OTHER, priority = Priority.HIGH, status = SRStatus.RECEIVED, title = f"[SSL] certbot renew {payload.status}", description = ( f"certbot auto-renew failed\n" f"status: {payload.status}\n" f"message: {payload.message}\n" f"web_server: {payload.web_server or '-'}\n" f"domains: {payload.renewed_domains or '-'}\n" f"exit_code: {payload.exit_code}\n" f"log: {payload.log_file or '-'}\n" f"at: {payload.timestamp or '-'}" ), requested_by = "ssl-autorenew-bot", ) db.add(sr) await db.commit() logger.warning("SSL 갱신 실패 SR 자동 생성: %s", sr_id) return { "ok": False, "status": payload.status, "sr_id": sr_id, } # dry-run 성공 또는 갱신 없음 return {"ok": True, "status": payload.status, "message": payload.message}