G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
454 lines
17 KiB
Python
454 lines
17 KiB
Python
"""
|
|
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}
|