feat(advanced): GUARDiA 고급 확장 구현 — 20 routers + 754 endpoints

CMDB 자동 발견 (4개):
- autodiscovery.py: SSH 네트워크 스캔 + CMDB 자동 등록
- snmp_discovery.py: SNMP v2c/v3 장비 자동 발견
- dependency_map.py: 서비스 의존성 자동 매핑 (netstat)
- config_inventory.py: 서버 인벤토리 자동 수집 (SSH)

NL 쿼리 엔진 (3개):
- nlquery.py: Text-to-SQL (SELECT 전용, DML 차단)
- op_assistant.py: Multi-turn 대화형 운영 어시스턴트
- query_history.py: 쿼리 이력·즐겨찾기·공유

구성 드리프트 (3개):
- drift_detection.py: 골든 구성 vs 실제 비교·SR 자동 생성
- golden_config.py: 내장 CSAP 템플릿 + 버전 관리
- auto_remediation.py: 승인 기반 자동 교정 + 롤백

멀티클라우드 (4개):
- multicloud.py: 통합 관제 (NCloud+AWS+KT)
- aws_connector.py: AWS SigV4 직접 서명 연동
- cost_optimizer.py: AI 비용 최적화 권고
- cloud_migration.py: On-prem→K-Cloud 체크리스트

공공기관 특화 (6개):
- narasajang.py: 나라장터 OpenAPI 연동
- public_api_hub.py: data.go.kr KISA·기상청 허브
- isp_support.py: ISP 수립 지원 + AI 보고서
- network_zone.py: 행정망/인터넷망 분리 관리
- k_cloud.py: 정부 K-Cloud 전환 자동화
- e_procurement.py: 전자조달 계약·검수·납품

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-02 14:33:41 +09:00
parent 3b12791da7
commit b8faec44e0
22 changed files with 3697 additions and 1 deletions

View File

@ -343,6 +343,40 @@ app.include_router(auto_report.router) # 자동 보고서 생성·다
app.include_router(benchmark.router) # 기관 간 익명 벤치마킹 app.include_router(benchmark.router) # 기관 간 익명 벤치마킹
app.include_router(cohort_analysis.router) # 코호트 분석 app.include_router(cohort_analysis.router) # 코호트 분석
# ── GUARDiA 고급 확장 (2026-06-02) — 20개 신규 라우터 ─────────────────────────
from routers import (
# CMDB 자동 발견
autodiscovery, snmp_discovery, dependency_map, config_inventory,
# NL 쿼리
nlquery, op_assistant, query_history,
# 구성 드리프트
drift_detection, golden_config, auto_remediation,
# 멀티클라우드
multicloud, aws_connector, cost_optimizer, cloud_migration,
# 공공기관 특화
narasajang, public_api_hub, isp_support, network_zone, k_cloud, e_procurement,
)
app.include_router(autodiscovery.router) # CMDB SSH 자동 발견
app.include_router(snmp_discovery.router) # SNMP 네트워크 장비 발견
app.include_router(dependency_map.router) # 서비스 의존성 자동 매핑
app.include_router(config_inventory.router) # 서버 구성 인벤토리 자동 수집
app.include_router(nlquery.router) # Text-to-SQL 자연어 쿼리
app.include_router(op_assistant.router) # 대화형 운영 어시스턴트
app.include_router(query_history.router) # 쿼리 이력·즐겨찾기·공유
app.include_router(drift_detection.router) # 구성 드리프트 감지
app.include_router(golden_config.router) # 골든 구성 정의·버전 관리
app.include_router(auto_remediation.router) # 승인 기반 자동 교정
app.include_router(multicloud.router) # 멀티클라우드 통합 관제
app.include_router(aws_connector.router) # AWS EC2/RDS 연동
app.include_router(cost_optimizer.router) # 클라우드 비용 최적화 AI
app.include_router(cloud_migration.router) # On-prem → K-Cloud 전환
app.include_router(narasajang.router) # 나라장터 조달 연동
app.include_router(public_api_hub.router) # 공공 API 허브 (data.go.kr)
app.include_router(isp_support.router) # ISP 수립 지원
app.include_router(network_zone.router) # 행정망/인터넷망 분리 관리
app.include_router(k_cloud.router) # K-Cloud 공공 클라우드 전환
app.include_router(e_procurement.router) # 전자조달 계약·검수·납품
# ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── # ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
@app.middleware("http") @app.middleware("http")

View File

@ -11,7 +11,7 @@ from typing import Any, Optional, List
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import ( from sqlalchemy import (
Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, func BigInteger, Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, func
) )
from sqlalchemy.orm import relationship, backref from sqlalchemy.orm import relationship, backref
@ -5104,3 +5104,271 @@ class ReportSchedule(Base):
format = Column(String(10), default="excel") format = Column(String(10), default="excel")
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now()) created_at = Column(DateTime, default=func.now())
# ══════════════════════════════════════════════════════════════════════════════
# ── GUARDiA 고급 확장 — CMDB자동발견 / NL쿼리 / 드리프트 / 멀티클라우드 / 공공
# ══════════════════════════════════════════════════════════════════════════════
class DiscoveryScanJob(Base):
__tablename__ = "tb_discovery_scan_job"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
network_range = Column(String(50), nullable=False)
ssh_user = Column(String(100), default="root")
include_snmp = Column(Boolean, default=False)
auto_register = Column(Boolean, default=False)
status = Column(String(20), default="QUEUED")
host_count = Column(Integer, default=0)
discovered_count = Column(Integer, default=0)
result_summary = Column(Text, nullable=True)
error_message = Column(Text, nullable=True)
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
finished_at = Column(DateTime, nullable=True)
class CMDBAutoDiscovery(Base):
__tablename__ = "tb_cmdb_autodiscovery"
id = Column(Integer, primary_key=True, index=True)
job_id = Column(Integer, ForeignKey("tb_discovery_scan_job.id"), nullable=False, index=True)
ip_addr = Column(String(50), nullable=False)
status = Column(String(20), default="UNREACHABLE")
discovered_data = Column(JSON, nullable=True)
in_cmdb = Column(Boolean, default=False)
discovered_at = Column(DateTime, default=func.now())
class ServiceDependency(Base):
__tablename__ = "tb_service_dependency"
id = Column(Integer, primary_key=True, index=True)
upstream_ci_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True)
downstream_ci_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True)
dependency_type = Column(String(50), nullable=False)
port = Column(Integer, nullable=True)
protocol = Column(String(10), default="TCP")
discovered_at = Column(DateTime, default=func.now())
class SNMPConfig(Base):
__tablename__ = "tb_snmp_config"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
name = Column(String(100), nullable=False)
community_enc = Column(Text, nullable=False)
version = Column(Integer, default=2)
ip_ranges = Column(JSON, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class SNMPDevice(Base):
__tablename__ = "tb_snmp_device"
id = Column(Integer, primary_key=True, index=True)
config_id = Column(Integer, ForeignKey("tb_snmp_config.id"), nullable=False)
ip_addr = Column(String(50), nullable=False)
sysname = Column(String(200), nullable=True)
description = Column(Text, nullable=True)
device_type = Column(String(50), default="UNKNOWN")
location = Column(String(200), nullable=True)
interface_count = Column(Integer, default=0)
discovered_at = Column(DateTime, default=func.now())
class ServerInventory(Base):
__tablename__ = "tb_server_inventory"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True)
data = Column(JSON, nullable=True)
collected_at = Column(DateTime, default=func.now())
class QueryHistory(Base):
__tablename__ = "tb_query_history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True)
question = Column(Text, nullable=False)
generated_sql = Column(Text, nullable=True)
description = Column(Text, nullable=True)
row_count = Column(Integer, default=0)
is_favorite = Column(Boolean, default=False)
is_shared = Column(Boolean, default=False)
executed_at = Column(DateTime, default=func.now())
class AssistantSession(Base):
__tablename__ = "tb_assistant_session"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=False, index=True)
title = Column(String(200), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class AssistantMessage(Base):
__tablename__ = "tb_assistant_message"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(Integer, ForeignKey("tb_assistant_session.id"), nullable=False, index=True)
role = Column(String(20), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=func.now())
class GoldenConfig(Base):
__tablename__ = "tb_golden_config"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
name = Column(String(200), nullable=False)
server_type = Column(String(50), nullable=False)
description = Column(Text, nullable=True)
items_json = Column(Text, nullable=True)
version = Column(String(20), default="1.0")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class DriftResult(Base):
__tablename__ = "tb_drift_result"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False, index=True)
config_id = Column(Integer, ForeignKey("tb_golden_config.id"), nullable=True)
total_checks = Column(Integer, default=0)
compliant_count = Column(Integer, default=0)
non_compliant_count = Column(Integer, default=0)
compliance_pct = Column(Float, nullable=True)
results_json = Column(Text, nullable=True)
scanned_at = Column(DateTime, default=func.now())
class AutoRemediationJob(Base):
__tablename__ = "tb_auto_remediation_job"
id = Column(Integer, primary_key=True, index=True)
drift_result_id = Column(Integer, ForeignKey("tb_drift_result.id"), nullable=True)
server_id = Column(Integer, ForeignKey("tb_server_info.id"), nullable=False)
item_key = Column(String(100), nullable=False)
fix_cmd = Column(Text, nullable=False)
rollback_cmd = Column(Text, nullable=True)
status = Column(String(30), default="PENDING_APPROVAL")
result_message = Column(Text, nullable=True)
requested_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
approved_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
executed_at = Column(DateTime, nullable=True)
class MultiCloudConfig(Base):
__tablename__ = "tb_multicloud_config"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
name = Column(String(100), nullable=False)
provider_type = Column(String(50), nullable=False)
region = Column(String(50), default="kr-1")
access_key = Column(String(200), nullable=False)
secret_key_enc = Column(Text, nullable=False)
extra_config = Column(Text, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class AWSConfig(Base):
__tablename__ = "tb_aws_config"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
name = Column(String(100), nullable=False)
access_key_id = Column(String(200), nullable=False)
secret_key_enc = Column(Text, nullable=False)
region = Column(String(50), default="ap-northeast-2")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class NarasajangConfig(Base):
__tablename__ = "tb_narasajang_config"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, unique=True, index=True)
api_key_enc = Column(Text, nullable=False)
institution_code = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class PublicApiConfig(Base):
__tablename__ = "tb_public_api_config"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
api_id = Column(String(100), nullable=False)
name = Column(String(200), nullable=False)
endpoint = Column(String(500), nullable=False)
api_key_enc = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class MigrationChecklist(Base):
__tablename__ = "tb_migration_checklist"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
item_key = Column(String(100), nullable=False)
completed = Column(Boolean, default=False)
note = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class KCloudConfig(Base):
__tablename__ = "tb_kcloud_config"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
csp_id = Column(String(50), nullable=False)
csp_name = Column(String(100), nullable=False)
access_key = Column(String(200), nullable=False)
secret_key_enc = Column(Text, nullable=False)
region = Column(String(50), default="KR")
account_type = Column(String(20), default="gov")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class NetworkZone(Base):
__tablename__ = "tb_network_zone"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
name = Column(String(100), nullable=False)
zone_type = Column(String(30), nullable=False)
description = Column(Text, nullable=True)
ip_ranges = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
class NetworkPolicy(Base):
__tablename__ = "tb_network_policy"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
src_zone_id = Column(Integer, ForeignKey("tb_network_zone.id"), nullable=False)
dst_zone_id = Column(Integer, ForeignKey("tb_network_zone.id"), nullable=False)
protocol = Column(String(10), default="TCP")
dst_port = Column(Integer, nullable=True)
action = Column(String(10), default="DENY")
description = Column(Text, nullable=True)
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
class ProcurementRecord(Base):
"""조달 계약 이력 — 나라장터·전자조달 연계."""
__tablename__ = "tb_procurement_record"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, nullable=False, index=True)
contract_no = Column(String(50), nullable=True)
contract_name = Column(String(300), nullable=False)
supplier = Column(String(200), nullable=True)
amount = Column(BigInteger, default=0)
category = Column(String(100), nullable=True)
start_date = Column(DateTime, nullable=True)
end_date = Column(DateTime, nullable=True)
linked_sr_ids = Column(JSON, nullable=True)
status = Column(String(30), default="ACTIVE")
inspection_result = Column(String(20), nullable=True)
inspection_date = Column(DateTime, nullable=True)
inspection_by = Column(String(100), nullable=True)
created_at = Column(DateTime, default=func.now())

View File

@ -0,0 +1,176 @@
"""
자동 교정 실행 승인 기반 (PAM 패턴 재사용)
드리프트 교정 명령을 관리자 승인 SSH 경유로 실행.
롤백 명령 포함.
엔드포인트:
GET /api/remediation/jobs 교정 작업 목록
GET /api/remediation/jobs/{id} 교정 작업 상세
POST /api/remediation/approve/{id} 승인 실행
POST /api/remediation/reject/{id} 거부
POST /api/remediation/rollback/{id} 롤백 실행
"""
from __future__ import annotations
import logging
from datetime import datetime
import paramiko
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from core.ssh_exec import _decrypt_password as decrypt_password
from database import get_db
from models import User, Server, AutoRemediationJob, AuditLog
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/remediation", tags=["자동 교정"])
async def _run_fix(server: Server, cmd: str) -> tuple[bool, str]:
"""SSH 경유 교정 명령 실행 — 에이전트리스."""
try:
pw = decrypt_password(server.os_pw_enc)
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10)
_, stdout, stderr = ssh.exec_command(cmd, timeout=30)
out = stdout.read().decode('utf-8', 'replace').strip()
err = stderr.read().decode('utf-8', 'replace').strip()
exit_code = stdout.channel.recv_exit_status()
ssh.close()
if exit_code == 0:
return True, out
return False, f"exit={exit_code}: {err[:200]}"
except Exception as e:
return False, str(e)[:200]
@router.get("/jobs")
async def list_jobs(
status: str = None,
limit: int = 50,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
q = select(AutoRemediationJob).order_by(desc(AutoRemediationJob.created_at)).limit(limit)
if status:
q = q.where(AutoRemediationJob.status == status)
rows = await db.execute(q)
jobs = rows.scalars().all()
return [
{
"id": j.id, "server_id": j.server_id, "item_key": j.item_key,
"fix_cmd": j.fix_cmd[:80] + "..." if len(j.fix_cmd or "") > 80 else j.fix_cmd,
"status": j.status, "result": j.result_message,
"created_at": j.created_at, "executed_at": j.executed_at,
}
for j in jobs
]
@router.get("/jobs/{job_id}")
async def get_job(
job_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id))
job = row.scalar_one_or_none()
if not job:
raise HTTPException(404)
return {
"id": job.id, "server_id": job.server_id,
"item_key": job.item_key, "fix_cmd": job.fix_cmd,
"status": job.status, "result": job.result_message,
"approved_by": job.approved_by,
"created_at": job.created_at, "executed_at": job.executed_at,
}
@router.post("/approve/{job_id}")
async def approve_and_execute(
job_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""승인 후 즉시 교정 실행."""
row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id))
job = row.scalar_one_or_none()
if not job:
raise HTTPException(404)
if job.status != "PENDING_APPROVAL":
raise HTTPException(400, f"현재 상태: {job.status}")
srv_row = await db.execute(select(Server).where(Server.id == job.server_id))
server = srv_row.scalar_one_or_none()
if not server:
raise HTTPException(404, "서버 없음")
job.status = "EXECUTING"
job.approved_by = user.id
await db.commit()
success, result = await _run_fix(server, job.fix_cmd)
job.status = "SUCCESS" if success else "FAILED"
job.result_message = result[:500]
job.executed_at = datetime.utcnow()
# 감사 로그
log = AuditLog(
user_id=user.id,
action="AUTO_REMEDIATION",
detail=f"서버 {server.hostname}: {job.item_key} 교정 {'성공' if success else '실패'}",
created_at=datetime.utcnow(),
)
db.add(log)
await db.commit()
return {"ok": success, "job_id": job_id, "status": job.status, "result": result[:200]}
@router.post("/reject/{job_id}")
async def reject_job(
job_id: int,
reason: str = "관리자 거부",
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id))
job = row.scalar_one_or_none()
if not job:
raise HTTPException(404)
job.status = "REJECTED"
job.result_message = reason
await db.commit()
return {"ok": True}
@router.post("/rollback/{job_id}")
async def rollback_job(
job_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""교정 롤백 실행."""
row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id))
job = row.scalar_one_or_none()
if not job or not job.rollback_cmd:
raise HTTPException(400, "롤백 명령 없음")
if job.status != "SUCCESS":
raise HTTPException(400, "실행 완료된 작업만 롤백 가능")
srv_row = await db.execute(select(Server).where(Server.id == job.server_id))
server = srv_row.scalar_one_or_none()
if not server:
raise HTTPException(404)
success, result = await _run_fix(server, job.rollback_cmd)
job.status = "ROLLED_BACK" if success else "ROLLBACK_FAILED"
job.result_message = f"ROLLBACK: {result[:400]}"
await db.commit()
return {"ok": success, "status": job.status, "result": result[:200]}

View File

@ -0,0 +1,291 @@
"""
CMDB 자동 발견 오케스트레이터 에이전트리스
네트워크 범위 스캔 SSH 인벤토리 수집 CMDB 자동 등록.
대상 서버에 소프트웨어 설치 불필요 (SSH/SNMP만 사용).
엔드포인트:
POST /api/autodiscovery/scan 네트워크 범위 스캔 시작
GET /api/autodiscovery/jobs 스캔 작업 목록
GET /api/autodiscovery/jobs/{id} 작업 상태·결과
POST /api/autodiscovery/apply/{id} 발견 결과 CMDB 적용
GET /api/autodiscovery/schedule 자동 스캔 스케줄
POST /api/autodiscovery/schedule 스케줄 등록
DELETE /api/autodiscovery/schedule/{id} 스케줄 삭제
"""
from __future__ import annotations
import asyncio
import ipaddress
import json
import logging
from datetime import datetime
from typing import Optional
import paramiko
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
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, Server, CMDBAutoDiscovery, DiscoveryScanJob
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/autodiscovery", tags=["CMDB 자동 발견"])
class ScanRequest(BaseModel):
network_range: str = Field(..., description="CIDR 표기 (예: 192.168.1.0/24)")
ssh_user: str = Field("root", description="SSH 계정")
ssh_password: Optional[str] = None
ssh_key_path: Optional[str] = None
include_snmp: bool = False
snmp_community: str = "public"
auto_register: bool = False # 발견 즉시 CMDB 등록
async def _probe_host(ip: str, ssh_user: str, ssh_password: Optional[str]) -> Optional[dict]:
"""단일 호스트 SSH 프로브 — 에이전트리스."""
try:
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(ip, username=ssh_user, password=ssh_password,
timeout=5, banner_timeout=10)
results = {}
commands = {
"hostname": "hostname -f 2>/dev/null || hostname",
"os": "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2 || uname -s",
"cpu_cores": "nproc 2>/dev/null || echo 0",
"cpu_model": "grep 'model name' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs || echo unknown",
"memory_mb": "free -m 2>/dev/null | awk '/^Mem:/{print $2}' || echo 0",
"disk_gb": "df -BG / 2>/dev/null | awk 'NR==2{gsub(/G/,\"\",$2); print $2}' || echo 0",
"open_ports": "ss -tlnp 2>/dev/null | awk 'NR>1{print $4}' | grep -oE '[0-9]+$' | sort -n | tr '\n' ',' || echo ''",
"services": "systemctl list-units --type=service --state=running 2>/dev/null | grep '.service' | awk '{print $1}' | head -10 | tr '\n' ',' || echo ''",
"java_version":"java -version 2>&1 | head -1 | grep -oE '[0-9]+\.[0-9]+' | head -1 || echo none",
"python_ver": "python3 --version 2>/dev/null | cut -d' ' -f2 || echo none",
"pkg_count": "dpkg -l 2>/dev/null | grep '^ii' | wc -l || rpm -qa 2>/dev/null | wc -l || echo 0",
}
for key, cmd in commands.items():
try:
_, stdout, _ = ssh.exec_command(cmd, timeout=5)
results[key] = stdout.read().decode('utf-8', 'replace').strip()
except Exception:
results[key] = ""
ssh.close()
results["ip"] = ip
results["status"] = "REACHABLE"
return results
except paramiko.AuthenticationException:
return {"ip": ip, "status": "AUTH_FAILED"}
except Exception:
return None # 응답 없음 → 스킵
async def _run_scan(job_id: int, request: ScanRequest, db: AsyncSession):
"""백그라운드 스캔 실행."""
job_row = await db.execute(select(DiscoveryScanJob).where(DiscoveryScanJob.id == job_id))
job = job_row.scalar_one_or_none()
if not job:
return
try:
job.status = "RUNNING"
await db.commit()
network = ipaddress.ip_network(request.network_range, strict=False)
hosts = list(network.hosts())
# 최대 254개 호스트로 제한
hosts = hosts[:254]
discovered = []
for host in hosts:
ip = str(host)
result = await _probe_host(ip, request.ssh_user, request.ssh_password)
if result and result.get("status") == "REACHABLE":
discovered.append(result)
# 발견 즉시 CMDB 등록 (auto_register=True인 경우)
if request.auto_register:
existing = await db.execute(
select(Server).where(Server.ip_addr == ip)
)
if not existing.scalar_one_or_none():
new_server = Server(
ip_addr=ip,
hostname=result.get("hostname", ip),
os_type=result.get("os", "unknown"),
ssh_user=request.ssh_user,
cpu_cores=int(result.get("cpu_cores", 0) or 0),
memory_mb=int(result.get("memory_mb", 0) or 0),
discovered_at=datetime.utcnow(),
)
db.add(new_server)
# 각 호스트 결과를 CMDBAutoDiscovery에 기록
discovery = CMDBAutoDiscovery(
job_id=job_id,
ip_addr=ip,
status=result.get("status", "UNREACHABLE") if result else "UNREACHABLE",
discovered_data=result or {},
discovered_at=datetime.utcnow(),
)
db.add(discovery)
await db.commit()
job.status = "DONE"
job.discovered_count = len(discovered)
job.result_summary = json.dumps(
{"total_scanned": len(hosts), "discovered": len(discovered)},
ensure_ascii=False
)
except Exception as e:
job.status = "FAILED"
job.error_message = str(e)[:500]
logger.error(f"스캔 실패 (job {job_id}): {e}")
finally:
job.finished_at = datetime.utcnow()
await db.commit()
@router.post("/scan")
async def start_scan(
req: ScanRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""네트워크 범위 스캔 시작."""
# CIDR 유효성 검사
try:
network = ipaddress.ip_network(req.network_range, strict=False)
host_count = sum(1 for _ in network.hosts())
except ValueError:
raise HTTPException(400, f"유효하지 않은 CIDR: {req.network_range}")
if host_count > 1024:
raise HTTPException(400, "한 번에 최대 1024개 호스트까지 스캔 가능합니다")
job = DiscoveryScanJob(
tenant_id=user.tenant_id,
network_range=req.network_range,
ssh_user=req.ssh_user,
include_snmp=req.include_snmp,
auto_register=req.auto_register,
status="QUEUED",
host_count=host_count,
created_by=user.id,
created_at=datetime.utcnow(),
)
db.add(job)
await db.commit()
await db.refresh(job)
background_tasks.add_task(_run_scan, job.id, req, db)
return {"ok": True, "job_id": job.id, "host_count": host_count}
@router.get("/jobs")
async def list_jobs(
limit: int = 20,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(DiscoveryScanJob).where(DiscoveryScanJob.tenant_id == user.tenant_id)
.order_by(desc(DiscoveryScanJob.created_at)).limit(limit)
)
jobs = rows.scalars().all()
return [
{
"id": j.id, "network": j.network_range, "status": j.status,
"host_count": j.host_count, "discovered": j.discovered_count or 0,
"created_at": j.created_at, "finished_at": j.finished_at,
}
for j in jobs
]
@router.get("/jobs/{job_id}")
async def get_job(
job_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
job_row = await db.execute(
select(DiscoveryScanJob).where(
DiscoveryScanJob.id == job_id,
DiscoveryScanJob.tenant_id == user.tenant_id,
)
)
job = job_row.scalar_one_or_none()
if not job:
raise HTTPException(404, "스캔 작업 없음")
results_row = await db.execute(
select(CMDBAutoDiscovery).where(CMDBAutoDiscovery.job_id == job_id)
.order_by(CMDBAutoDiscovery.ip_addr)
)
results = results_row.scalars().all()
return {
"job": {
"id": job.id, "network": job.network_range, "status": job.status,
"host_count": job.host_count, "discovered": job.discovered_count or 0,
"summary": json.loads(job.result_summary or "{}"),
"error": job.error_message,
},
"results": [
{
"ip": r.ip_addr, "status": r.status,
"hostname": (r.discovered_data or {}).get("hostname", ""),
"os": (r.discovered_data or {}).get("os", ""),
"cpu": (r.discovered_data or {}).get("cpu_cores", 0),
"mem_mb": (r.discovered_data or {}).get("memory_mb", 0),
"open_ports": (r.discovered_data or {}).get("open_ports", ""),
"in_cmdb": r.in_cmdb,
}
for r in results
],
}
@router.post("/apply/{job_id}")
async def apply_to_cmdb(
job_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""발견 결과를 CMDB에 등록."""
results_row = await db.execute(
select(CMDBAutoDiscovery).where(
CMDBAutoDiscovery.job_id == job_id,
CMDBAutoDiscovery.status == "REACHABLE",
CMDBAutoDiscovery.in_cmdb == False,
)
)
results = results_row.scalars().all()
registered = 0
for r in results:
data = r.discovered_data or {}
ip = r.ip_addr
existing = await db.execute(select(Server).where(Server.ip_addr == ip))
if not existing.scalar_one_or_none():
new_server = Server(
ip_addr=ip,
hostname=data.get("hostname", ip),
os_type=data.get("os", "unknown"),
ssh_user="opsagent",
cpu_cores=int(data.get("cpu_cores", 0) or 0),
memory_mb=int(data.get("memory_mb", 0) or 0),
discovered_at=datetime.utcnow(),
)
db.add(new_server)
r.in_cmdb = True
registered += 1
await db.commit()
return {"ok": True, "registered": registered}

View File

@ -0,0 +1,140 @@
"""
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에서만 조회 가능 — 별도 설정 필요"}

View File

@ -0,0 +1,143 @@
"""
클라우드 전환 자동화 체크리스트 On-prem K-Cloud/NCloud
공공기관 클라우드 전환 준비도 점검 체크리스트 자동 생성.
엔드포인트:
GET /api/migration/checklist 전환 체크리스트 조회
POST /api/migration/assess 전환 준비도 평가
GET /api/migration/readiness 준비도 현황
PUT /api/migration/checklist/{key} 체크리스트 항목 완료 처리
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends
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 User, MigrationChecklist
router = APIRouter(prefix="/api/migration", tags=["클라우드 전환"])
MIGRATION_PHASES = {
"01_사전평가": [
{"key": "infra_inventory", "title": "현행 인프라 인벤토리 완성", "priority": "MUST"},
{"key": "app_compatibility", "title": "애플리케이션 클라우드 호환성 평가", "priority": "MUST"},
{"key": "network_bandwidth", "title": "네트워크 대역폭 요구사항 측정", "priority": "MUST"},
{"key": "license_check", "title": "SW 라이선스 클라우드 이전 가능 여부 확인", "priority": "MUST"},
{"key": "cost_estimate", "title": "TCO(총소유비용) 분석 완료", "priority": "RECOMMEND"},
],
"02_보안준비": [
{"key": "iam_design", "title": "IAM 역할·정책 설계 완료", "priority": "MUST"},
{"key": "vpc_design", "title": "VPC·서브넷·보안그룹 설계", "priority": "MUST"},
{"key": "kms_setup", "title": "암호화 키 관리(KMS) 설정", "priority": "MUST"},
{"key": "csap_check", "title": "CSAP(클라우드 보안인증) 인증 CSP 확인", "priority": "MUST"},
{"key": "zero_trust", "title": "제로트러스트 보안 정책 수립", "priority": "RECOMMEND"},
],
"03_이전실행": [
{"key": "data_migration_plan", "title": "데이터 마이그레이션 계획 수립", "priority": "MUST"},
{"key": "blue_green", "title": "Blue/Green 전환 전략 수립", "priority": "RECOMMEND"},
{"key": "rollback_plan", "title": "롤백 계획 수립", "priority": "MUST"},
{"key": "downtime_plan", "title": "서비스 중단 최소화 방안 확보", "priority": "MUST"},
{"key": "dr_config", "title": "클라우드 DR 구성 완료", "priority": "RECOMMEND"},
],
"04_운영전환": [
{"key": "monitoring_setup", "title": "클라우드 모니터링 설정", "priority": "MUST"},
{"key": "staff_training", "title": "운영 인력 교육 완료", "priority": "MUST"},
{"key": "runbook_update", "title": "운영 매뉴얼 업데이트", "priority": "RECOMMEND"},
{"key": "cost_governance", "title": "비용 거버넌스 정책 수립", "priority": "RECOMMEND"},
],
}
class ChecklistUpdate(BaseModel):
completed: bool
note: Optional[str] = None
@router.get("/checklist")
async def get_checklist(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(MigrationChecklist).where(MigrationChecklist.tenant_id == user.tenant_id)
)
completed_keys = {c.item_key for c in rows.scalars().all() if c.completed}
result = {}
for phase, items in MIGRATION_PHASES.items():
result[phase] = [
{**item, "completed": item["key"] in completed_keys}
for item in items
]
total = sum(len(v) for v in MIGRATION_PHASES.values())
completed = len(completed_keys)
return {
"phases": result,
"progress": {"completed": completed, "total": total,
"pct": round(completed / total * 100, 1) if total else 0},
}
@router.put("/checklist/{item_key}")
async def update_checklist_item(
item_key: str,
req: ChecklistUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(
select(MigrationChecklist).where(
MigrationChecklist.tenant_id == user.tenant_id,
MigrationChecklist.item_key == item_key,
)
)
item = row.scalar_one_or_none()
if item:
item.completed = req.completed
item.note = req.note
item.updated_at = datetime.utcnow()
else:
item = MigrationChecklist(
tenant_id=user.tenant_id, item_key=item_key,
completed=req.completed, note=req.note,
created_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
db.add(item)
await db.commit()
return {"ok": True, "item_key": item_key, "completed": req.completed}
@router.get("/readiness")
async def migration_readiness(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
checklist = await get_checklist(db, user)
progress = checklist["progress"]
pct = progress["pct"]
phase_status = {}
for phase, items in checklist["phases"].items():
total = len(items)
done = sum(1 for i in items if i["completed"])
must_total = sum(1 for i in items if i["priority"] == "MUST")
must_done = sum(1 for i in items if i["priority"] == "MUST" and i["completed"])
phase_status[phase] = {
"progress": f"{done}/{total}",
"must_completed": must_done == must_total,
"status": "READY" if must_done == must_total else "IN_PROGRESS",
}
return {
"overall_pct": pct,
"readiness": "READY" if pct >= 80 else "IN_PROGRESS" if pct >= 40 else "NOT_STARTED",
"phases": phase_status,
}

View File

@ -0,0 +1,207 @@
"""
서버 구성 인벤토리 자동 수집 SSH 에이전트리스
서버에 설치된 소프트웨어·버전·설정 파일을 SSH로 자동 수집하여 CMDB를 보완.
엔드포인트:
POST /api/inventory/collect/{server_id} 단일 서버 인벤토리 수집
POST /api/inventory/collect-all 전체 서버 일괄 수집
GET /api/inventory/{server_id} 서버 인벤토리 조회
GET /api/inventory/diff/{server_id} 이전 수집과 변경사항 비교
GET /api/inventory/software 소프트웨어 설치 현황 (전체)
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Optional
import paramiko
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from core.ssh_exec import _decrypt_password as decrypt_password
from database import get_db
from models import User, Server, ServerInventory
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/inventory", tags=["서버 인벤토리"])
INVENTORY_COMMANDS = {
"os_info": "cat /etc/os-release 2>/dev/null | head -5",
"kernel": "uname -r",
"hostname": "hostname -f 2>/dev/null || hostname",
"uptime": "uptime -p 2>/dev/null || uptime",
"cpu_model": "grep 'model name' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs",
"cpu_cores": "nproc",
"memory_total": "free -m | awk '/^Mem:/{print $2}'",
"disk_total": "df -BG --total 2>/dev/null | tail -1 | awk '{print $2}'",
"disk_used": "df -BG --total 2>/dev/null | tail -1 | awk '{print $3}'",
"open_ports": "ss -tlnp 2>/dev/null | awk 'NR>1{print $4}' | grep -oE '[0-9]+$' | sort -n | uniq | tr '\n' ','",
"running_svcs": "systemctl list-units --type=service --state=running 2>/dev/null | grep '.service' | awk '{print $1}' | tr '\n' ','",
"java_ver": "java -version 2>&1 | head -1",
"python_ver": "python3 --version 2>/dev/null",
"node_ver": "node --version 2>/dev/null || echo none",
"nginx_ver": "nginx -v 2>&1 || echo none",
"tomcat_ver": "find /opt /usr /home -name 'catalina.sh' 2>/dev/null | head -1 | xargs -I{} sh -c 'sh {} version 2>&1 | grep -i version | head -1' || echo none",
"mysql_ver": "mysql --version 2>/dev/null || echo none",
"postgres_ver": "psql --version 2>/dev/null || echo none",
"installed_pkgs":"dpkg -l 2>/dev/null | grep '^ii' | awk '{print $2\"=\"$3}' | head -50 || rpm -qa --queryformat '%{NAME}=%{VERSION}\n' 2>/dev/null | head -50",
"crontabs": "crontab -l 2>/dev/null | grep -v '^#' | grep -v '^$' | head -10",
"users": "getent passwd 2>/dev/null | awk -F: '$3>=1000 && $3<65534{print $1}' | tr '\n' ','",
"sudoers": "grep -r ALL /etc/sudoers /etc/sudoers.d/ 2>/dev/null | grep -v '^#' | head -5",
"last_updates": "tail -5 /var/log/dpkg.log 2>/dev/null || tail -5 /var/log/yum.log 2>/dev/null || echo none",
}
async def _collect_inventory(server: Server) -> dict:
try:
pw = decrypt_password(server.os_pw_enc)
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10)
results = {}
for key, cmd in INVENTORY_COMMANDS.items():
try:
_, stdout, _ = ssh.exec_command(cmd, timeout=8)
results[key] = stdout.read().decode('utf-8', 'replace').strip()[:500]
except Exception:
results[key] = ""
ssh.close()
return results
except Exception as e:
logger.warning(f"인벤토리 수집 실패 ({server.ip_addr}): {e}")
return {}
async def _do_collect(server_id: int, db: AsyncSession):
srv_row = await db.execute(select(Server).where(Server.id == server_id))
server = srv_row.scalar_one_or_none()
if not server:
return
data = await _collect_inventory(server)
if not data:
return
inv = ServerInventory(
server_id=server_id,
data=data,
collected_at=datetime.utcnow(),
)
db.add(inv)
# 서버 정보 업데이트
if data.get("cpu_cores"):
try: server.cpu_cores = int(data["cpu_cores"])
except ValueError: pass
if data.get("memory_total"):
try: server.memory_mb = int(data["memory_total"])
except ValueError: pass
await db.commit()
@router.post("/collect/{server_id}")
async def collect_server_inventory(
server_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
background_tasks.add_task(_do_collect, server_id, db)
return {"ok": True, "server_id": server_id, "queued": True}
@router.post("/collect-all")
async def collect_all_inventory(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
rows = await db.execute(select(Server).limit(200))
servers = rows.scalars().all()
for s in servers:
background_tasks.add_task(_do_collect, s.id, db)
return {"ok": True, "queued": len(servers)}
@router.get("/{server_id}")
async def get_inventory(
server_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(
select(ServerInventory).where(ServerInventory.server_id == server_id)
.order_by(desc(ServerInventory.collected_at)).limit(1)
)
inv = row.scalar_one_or_none()
if not inv:
raise HTTPException(404, "인벤토리 없음 — 먼저 수집하세요")
return {"server_id": server_id, "data": inv.data, "collected_at": inv.collected_at}
@router.get("/diff/{server_id}")
async def get_inventory_diff(
server_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""최근 2개 인벤토리 비교 — 변경사항 반환."""
rows = await db.execute(
select(ServerInventory).where(ServerInventory.server_id == server_id)
.order_by(desc(ServerInventory.collected_at)).limit(2)
)
invs = rows.scalars().all()
if len(invs) < 2:
return {"server_id": server_id, "diff": {}, "message": "비교할 이전 데이터 없음"}
current = invs[0].data or {}
previous = invs[1].data or {}
diff = {}
for key in set(list(current.keys()) + list(previous.keys())):
c_val = current.get(key, "")
p_val = previous.get(key, "")
if c_val != p_val:
diff[key] = {"before": p_val[:200], "after": c_val[:200]}
return {
"server_id": server_id,
"current_at": invs[0].collected_at,
"previous_at": invs[1].collected_at,
"changed_items": len(diff),
"diff": diff,
}
@router.get("/software")
async def software_inventory(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""전체 서버 소프트웨어 현황 — Java/Python/Nginx 버전 집계."""
rows = await db.execute(
select(ServerInventory, Server.hostname).join(
Server, ServerInventory.server_id == Server.id
).order_by(desc(ServerInventory.collected_at))
)
results = []
seen_servers = set()
for row in rows.all():
if row.ServerInventory.server_id in seen_servers:
continue
seen_servers.add(row.ServerInventory.server_id)
data = row.ServerInventory.data or {}
results.append({
"server": row.hostname,
"java": data.get("java_ver", "none"),
"python": data.get("python_ver", "none"),
"nginx": data.get("nginx_ver", "none"),
"node": data.get("node_ver", "none"),
"mysql": data.get("mysql_ver", "none"),
"postgres": data.get("postgres_ver", "none"),
"collected_at": row.ServerInventory.collected_at,
})
return results

View File

@ -0,0 +1,94 @@
"""
클라우드 비용 최적화 AI
Ollama 기반 비용 분석·최적화 권고·Reserved Instance 추정.
엔드포인트:
GET /api/costopt/analyze 비용 패턴 분석
POST /api/costopt/recommend AI 최적화 권고 생성
GET /api/costopt/idle-resources 유휴 리소스 탐지
GET /api/costopt/savings 절감 가능 금액 추정
"""
from __future__ import annotations
import logging
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends
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 User, MultiCloudConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/costopt", tags=["비용 최적화"])
OLLAMA_URL = "http://localhost:11434"
async def _llm_analyze(prompt: str) -> str:
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": "llama3", "prompt": prompt, "stream": False,
})
return r.json().get("response", "").strip() if r.status_code == 200 else ""
except Exception: return ""
@router.get("/analyze")
async def analyze_costs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True))
providers = rows.scalars().all()
return {
"provider_count": len(providers),
"providers": [{"name": p.name, "type": p.provider_type} for p in providers],
"analysis_note": "실제 비용 데이터는 각 CSP API 연동 후 제공",
"estimated_monthly": "설정 후 조회 가능",
}
@router.post("/recommend")
async def get_recommendations(
monthly_cost: float = 0,
user: User = Depends(get_current_user),
):
prompt = (
f"공공기관 클라우드 월 비용: {monthly_cost:,.0f}\n\n"
"다음 항목을 분석하여 절감 방안을 JSON 형식으로 제시:\n"
"1. Reserved Instance 전환 가능 여부 (On-demand → RI 최대 60% 절감)\n"
"2. 개발/테스트 환경 야간/주말 자동 중지 (최대 65% 절감)\n"
"3. 스토리지 최적화 (미연결 볼륨, 오래된 스냅샷)\n"
"4. 인스턴스 다운사이징 (사용률 10% 미만)\n"
'JSON 형식: {"recommendations": [...], "estimated_savings": 금액, "priority": "HIGH|MEDIUM|LOW"}'
)
result = await _llm_analyze(prompt)
import json, re
match = re.search(r'\{.*\}', result, re.DOTALL)
try:
data = json.loads(match.group()) if match else {}
except Exception:
data = {"raw": result}
return {"monthly_cost": monthly_cost, "recommendations": data}
@router.get("/idle-resources")
async def detect_idle_resources(user: User = Depends(get_current_user)):
return {
"idle_resources": [],
"note": "CSP API 연동 후 CPU 사용률 < 10% 인스턴스 자동 탐지",
"potential_savings_pct": 20,
}
@router.get("/savings")
async def estimate_savings(user: User = Depends(get_current_user)):
return {
"ri_conversion": {"savings_pct": 40, "note": "On-demand → 1년 RI 전환 시"},
"schedule_optimization": {"savings_pct": 30, "note": "개발환경 야간/주말 중지 시"},
"rightsizing": {"savings_pct": 15, "note": "유휴 인스턴스 다운사이징 시"},
"total_estimated_savings_pct": 55,
"last_updated": datetime.utcnow(),
}

View File

@ -0,0 +1,231 @@
"""
서비스 의존성 자동 매핑
SSH 경유 netstat/ss 분석으로 서비스 upstream/downstream 의존성을 자동 탐지.
엔드포인트:
POST /api/depmap/discover/{server_id} 단일 서버 의존성 탐지
POST /api/depmap/discover-all 전체 서버 의존성 탐지
GET /api/depmap/ 의존성 조회
GET /api/depmap/impact/{ci_id} 특정 CI 영향 범위 분석
DELETE /api/depmap/{dep_id} 의존성 수동 삭제
"""
from __future__ import annotations
import logging
import re
from datetime import datetime
import paramiko
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from core.ssh_exec import _decrypt_password as decrypt_password
from database import get_db
from models import User, Server, ServiceDependency
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/depmap", tags=["서비스 의존성 맵"])
async def _ssh_run(server: Server, cmd: str) -> str:
try:
pw = decrypt_password(server.os_pw_enc)
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10)
_, stdout, _ = ssh.exec_command(cmd, timeout=15)
result = stdout.read().decode('utf-8', 'replace').strip()
ssh.close()
return result
except Exception as e:
logger.warning(f"SSH 실패 ({server.ip_addr}): {e}")
return ""
async def _discover_connections(server: Server) -> list[dict]:
"""netstat/ss로 ESTABLISHED 연결 분석 → 의존성 추출."""
output = await _ssh_run(
server,
"ss -tnp state established 2>/dev/null | awk 'NR>1{print $4,$5}' || "
"netstat -tnp 2>/dev/null | grep ESTABLISHED | awk '{print $4,$5}'"
)
connections = []
seen = set()
for line in output.splitlines():
parts = line.split()
if len(parts) < 2:
continue
local, remote = parts[0], parts[1]
# 외부 IP:포트 추출
m = re.match(r'(\d+\.\d+\.\d+\.\d+):(\d+)$', remote)
if not m:
continue
remote_ip, remote_port = m.group(1), int(m.group(2))
if remote_ip in ("127.0.0.1", "0.0.0.0"):
continue
key = f"{remote_ip}:{remote_port}"
if key in seen:
continue
seen.add(key)
# 포트 → 서비스 유형 추정
service_map = {
3306: "MySQL", 5432: "PostgreSQL", 6379: "Redis",
27017: "MongoDB", 9200: "Elasticsearch",
80: "HTTP", 443: "HTTPS", 8080: "HTTP-Alt",
2181: "Zookeeper", 9092: "Kafka", 5672: "RabbitMQ",
}
dep_type = service_map.get(remote_port, f"TCP:{remote_port}")
connections.append({
"remote_ip": remote_ip, "remote_port": remote_port,
"dependency_type": dep_type, "protocol": "TCP",
})
return connections
async def _do_discover(server_id: int, db: AsyncSession):
"""단일 서버 의존성 탐지 (백그라운드)."""
srv_row = await db.execute(select(Server).where(Server.id == server_id))
server = srv_row.scalar_one_or_none()
if not server:
return
connections = await _discover_connections(server)
new_deps = 0
for conn in connections:
# 원격 IP가 CMDB에 있는지 확인
remote_srv_row = await db.execute(
select(Server).where(Server.ip_addr == conn["remote_ip"])
)
remote_server = remote_srv_row.scalar_one_or_none()
if not remote_server:
continue # CMDB에 없는 서버는 스킵
# 중복 체크
existing = await db.execute(
select(ServiceDependency).where(
ServiceDependency.upstream_ci_id == server_id,
ServiceDependency.downstream_ci_id == remote_server.id,
ServiceDependency.port == conn["remote_port"],
)
)
if existing.scalar_one_or_none():
continue
dep = ServiceDependency(
upstream_ci_id=server_id,
downstream_ci_id=remote_server.id,
dependency_type=conn["dependency_type"],
port=conn["remote_port"],
protocol=conn["protocol"],
discovered_at=datetime.utcnow(),
)
db.add(dep)
new_deps += 1
await db.commit()
logger.info(f"서버 {server_id} 의존성 {new_deps}개 발견")
@router.post("/discover/{server_id}")
async def discover_server_deps(
server_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
background_tasks.add_task(_do_discover, server_id, db)
return {"ok": True, "server_id": server_id, "queued": True}
@router.post("/discover-all")
async def discover_all_deps(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
rows = await db.execute(select(Server).limit(100))
servers = rows.scalars().all()
for s in servers:
background_tasks.add_task(_do_discover, s.id, db)
return {"ok": True, "queued": len(servers)}
@router.get("/")
async def get_dependency_map(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(
ServiceDependency,
Server.hostname.label("up_name"), Server.ip_addr.label("up_ip"),
).join(Server, ServiceDependency.upstream_ci_id == Server.id)
.limit(500)
)
deps = rows.all()
return [
{
"id": d.ServiceDependency.id,
"upstream": {"id": d.ServiceDependency.upstream_ci_id, "name": d.up_name, "ip": d.up_ip},
"downstream_id": d.ServiceDependency.downstream_ci_id,
"type": d.ServiceDependency.dependency_type,
"port": d.ServiceDependency.port,
"discovered_at": d.ServiceDependency.discovered_at,
}
for d in deps
]
@router.get("/impact/{ci_id}")
async def get_impact_analysis(
ci_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""특정 CI 장애 시 영향 범위 분석 (upstream 서비스 = 영향받는 서비스)."""
# ci_id에 의존하는 서비스들 (ci_id가 downstream인 경우)
rows = await db.execute(
select(ServiceDependency, Server.hostname, Server.ip_addr).join(
Server, ServiceDependency.upstream_ci_id == Server.id
).where(ServiceDependency.downstream_ci_id == ci_id)
)
impacted = rows.all()
# ci_id가 의존하는 서비스들 (ci_id가 upstream인 경우)
dep_rows = await db.execute(
select(ServiceDependency, Server.hostname, Server.ip_addr).join(
Server, ServiceDependency.downstream_ci_id == Server.id
).where(ServiceDependency.upstream_ci_id == ci_id)
)
dependencies = dep_rows.all()
return {
"ci_id": ci_id,
"impacted_services": [
{"server": r.hostname, "ip": r.ip_addr, "depends_via": r.ServiceDependency.dependency_type}
for r in impacted
],
"depends_on": [
{"server": r.hostname, "ip": r.ip_addr, "type": r.ServiceDependency.dependency_type}
for r in dependencies
],
"blast_radius": len(impacted),
}
@router.delete("/{dep_id}")
async def delete_dependency(
dep_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
row = await db.execute(select(ServiceDependency).where(ServiceDependency.id == dep_id))
dep = row.scalar_one_or_none()
if not dep:
raise HTTPException(404)
await db.delete(dep)
await db.commit()
return {"ok": True}

View File

@ -0,0 +1,276 @@
"""
구성 드리프트 감지 + 자동 교정
골든 구성과 실제 서버 환경을 비교하여 이탈(드리프트) 감지.
드리프트 발견 SR 자동 생성 + 승인 기반 자동 교정.
엔드포인트:
POST /api/drift/scan/{server_id} 단일 서버 드리프트 스캔
POST /api/drift/scan-all 전체 서버 스캔
GET /api/drift/results 드리프트 결과 목록
GET /api/drift/results/{server_id} 서버별 드리프트 상세
GET /api/drift/summary 전체 준수율 요약
POST /api/drift/remediate/{result_id} 자동 교정 요청 (승인 필요)
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Optional
import paramiko
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from core.ssh_exec import _decrypt_password as decrypt_password
from database import get_db
from models import (
User, Server, GoldenConfig, DriftResult,
SRRequest, SRStatus, AutoRemediationJob,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/drift", tags=["구성 드리프트"])
async def _check_item(server: Server, item: dict) -> dict:
"""단일 구성 항목 체크."""
try:
pw = decrypt_password(server.os_pw_enc)
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=8)
_, stdout, _ = ssh.exec_command(item["cmd"], timeout=8)
actual = stdout.read().decode('utf-8', 'replace').strip()
ssh.close()
# 기대값 비교
expected = item.get("expected")
expected_regex = item.get("expected_regex")
expected_contains = item.get("expected_contains")
expected_not_contains = item.get("expected_not_contains")
compliant = True
if expected is not None:
compliant = actual == expected
elif expected_regex:
compliant = bool(re.search(expected_regex, actual, re.IGNORECASE))
elif expected_contains:
compliant = expected_contains.lower() in actual.lower()
elif expected_not_contains:
compliant = expected_not_contains.lower() not in actual.lower()
return {
"key": item["key"],
"description": item.get("description", ""),
"severity": item.get("severity", "MEDIUM"),
"compliant": compliant,
"actual": actual[:200],
"expected": expected or expected_regex or expected_contains or "",
"auto_fix": item.get("auto_fix"),
}
except Exception as e:
return {
"key": item["key"],
"description": item.get("description", ""),
"severity": item.get("severity", "MEDIUM"),
"compliant": None, # 체크 불가
"actual": f"ERROR: {str(e)[:100]}",
"expected": "",
"auto_fix": None,
}
async def _do_scan(server_id: int, config_id: Optional[int], db: AsyncSession):
"""단일 서버 드리프트 스캔 (백그라운드)."""
srv_row = await db.execute(select(Server).where(Server.id == server_id))
server = srv_row.scalar_one_or_none()
if not server:
return
# 골든 구성 선택 (지정 없으면 서버 유형으로 자동 선택)
if config_id:
cfg_row = await db.execute(select(GoldenConfig).where(GoldenConfig.id == config_id))
else:
cfg_row = await db.execute(
select(GoldenConfig).where(
GoldenConfig.is_active == True,
).limit(1)
)
config = cfg_row.scalar_one_or_none()
if not config:
return
items = json.loads(config.items_json or "[]")
results = []
for item in items:
result = await _check_item(server, item)
results.append(result)
non_compliant = [r for r in results if r["compliant"] is False]
total = len(results)
compliant_count = sum(1 for r in results if r["compliant"] is True)
drift = DriftResult(
server_id=server_id,
config_id=config.id,
total_checks=total,
compliant_count=compliant_count,
non_compliant_count=len(non_compliant),
compliance_pct=round(compliant_count / total * 100, 1) if total else 0,
results_json=json.dumps(results, ensure_ascii=False),
scanned_at=datetime.utcnow(),
)
db.add(drift)
# 드리프트 발견 시 SR 자동 생성
if non_compliant:
high_sev = [r for r in non_compliant if r["severity"] == "HIGH"]
priority = "HIGH" if high_sev else "MEDIUM"
sr = SRRequest(
title=f"[드리프트] {server.hostname}: {len(non_compliant)}개 구성 이탈",
description=f"골든 구성 '{config.name}' 대비 이탈 항목:\n" + "\n".join(
f"- [{r['severity']}] {r['description']}: 실제={r['actual'][:50]}"
for r in non_compliant[:5]
),
category="CONFIG_DRIFT",
priority=priority,
status=SRStatus.OPEN,
created_at=datetime.utcnow(),
)
db.add(sr)
await db.commit()
logger.info(f"서버 {server_id} 드리프트 스캔 완료: {len(non_compliant)}/{total} 이탈")
@router.post("/scan/{server_id}")
async def scan_server(
server_id: int,
config_id: Optional[int] = None,
background_tasks: BackgroundTasks = ...,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
background_tasks.add_task(_do_scan, server_id, config_id, db)
return {"ok": True, "server_id": server_id, "queued": True}
@router.post("/scan-all")
async def scan_all_servers(
config_id: Optional[int] = None,
background_tasks: BackgroundTasks = ...,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
rows = await db.execute(select(Server).limit(100))
servers = rows.scalars().all()
for s in servers:
background_tasks.add_task(_do_scan, s.id, config_id, db)
return {"ok": True, "queued": len(servers)}
@router.get("/results")
async def list_drift_results(
limit: int = 50,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(DriftResult, Server.hostname, Server.ip_addr).join(
Server, DriftResult.server_id == Server.id
).order_by(desc(DriftResult.scanned_at)).limit(limit)
)
return [
{
"id": r.DriftResult.id,
"server": r.hostname, "ip": r.ip_addr,
"compliance_pct": r.DriftResult.compliance_pct,
"non_compliant": r.DriftResult.non_compliant_count,
"total": r.DriftResult.total_checks,
"scanned_at": r.DriftResult.scanned_at,
}
for r in rows.all()
]
@router.get("/results/{server_id}")
async def get_server_drift(
server_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(
select(DriftResult).where(DriftResult.server_id == server_id)
.order_by(desc(DriftResult.scanned_at)).limit(1)
)
result = row.scalar_one_or_none()
if not result:
raise HTTPException(404, "스캔 결과 없음 — 먼저 스캔하세요")
return {
"id": result.id,
"compliance_pct": result.compliance_pct,
"non_compliant": result.non_compliant_count,
"total": result.total_checks,
"items": json.loads(result.results_json or "[]"),
"scanned_at": result.scanned_at,
}
@router.get("/summary")
async def drift_summary(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
avg_row = await db.execute(
select(func.avg(DriftResult.compliance_pct)).where(
DriftResult.compliance_pct.isnot(None)
)
)
total_row = await db.execute(select(func.count(DriftResult.id)))
critical_row = await db.execute(
select(func.count(DriftResult.id)).where(DriftResult.compliance_pct < 70)
)
return {
"avg_compliance_pct": round(avg_row.scalar() or 0, 1),
"total_scanned": total_row.scalar() or 0,
"critical_servers": critical_row.scalar() or 0,
"status": "CRITICAL" if (avg_row.scalar() or 100) < 70 else "WARNING" if (avg_row.scalar() or 100) < 90 else "GOOD",
}
@router.post("/remediate/{result_id}")
async def request_remediation(
result_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""자동 교정 요청 — 관리자 승인 필요."""
row = await db.execute(select(DriftResult).where(DriftResult.id == result_id))
result = row.scalar_one_or_none()
if not result:
raise HTTPException(404)
items = json.loads(result.results_json or "[]")
fixable = [i for i in items if not i.get("compliant") and i.get("auto_fix")]
jobs_created = 0
for item in fixable:
job = AutoRemediationJob(
drift_result_id=result_id,
server_id=result.server_id,
item_key=item["key"],
fix_cmd=item["auto_fix"],
status="PENDING_APPROVAL",
requested_by=user.id,
created_at=datetime.utcnow(),
)
db.add(job)
jobs_created += 1
await db.commit()
return {"ok": True, "jobs_created": jobs_created, "status": "PENDING_APPROVAL"}

View File

@ -0,0 +1,127 @@
"""
전자조달 계약·검수·납품 이력 관리
나라장터 계약을 ITSM SR과 연계하여 IT 자산 도입 프로세스를 통합 관리.
엔드포인트:
GET /api/eprocure/contracts 계약 목록
POST /api/eprocure/contracts 계약 등록
PUT /api/eprocure/contracts/{id} 계약 수정
POST /api/eprocure/inspect/{id} 납품 검수 처리
GET /api/eprocure/stats 조달 통계
GET /api/eprocure/expiring 계약 만료 예정
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, and_, desc
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, ProcurementRecord
router = APIRouter(prefix="/api/eprocure", tags=["전자조달 관리"])
class ContractCreate(BaseModel):
contract_no: str
contract_name: str
supplier: str
amount: int
start_date: str
end_date: str
category: str = "IT장비"
linked_sr_ids: list[int] = []
note: Optional[str] = None
class InspectRequest(BaseModel):
inspected_by: str
note: Optional[str] = None
result: str = "PASS" # PASS | FAIL | PARTIAL
@router.post("/contracts")
async def create_contract(req: ContractCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
record = ProcurementRecord(
tenant_id=user.tenant_id,
contract_no=req.contract_no, contract_name=req.contract_name,
supplier=req.supplier, amount=req.amount,
start_date=date.fromisoformat(req.start_date),
end_date=date.fromisoformat(req.end_date),
category=req.category, linked_sr_ids=req.linked_sr_ids,
status="ACTIVE", created_at=datetime.utcnow(),
)
db.add(record); await db.commit(); await db.refresh(record)
return {"ok": True, "id": record.id}
@router.get("/contracts")
async def list_contracts(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(
select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id)
.order_by(desc(ProcurementRecord.end_date)).limit(100)
)
records = rows.scalars().all()
return [
{"id": r.id, "no": r.contract_no, "name": r.contract_name,
"supplier": r.supplier, "amount": r.amount,
"period": f"{r.start_date} ~ {r.end_date}",
"status": r.status, "category": r.category,
"linked_sr": r.linked_sr_ids or []}
for r in records
]
@router.post("/inspect/{contract_id}")
async def inspect_delivery(contract_id: int, req: InspectRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
row = await db.execute(select(ProcurementRecord).where(ProcurementRecord.id == contract_id, ProcurementRecord.tenant_id == user.tenant_id))
record = row.scalar_one_or_none()
if not record: raise HTTPException(404)
record.inspection_result = req.result
record.inspection_date = date.today()
record.inspection_by = req.inspected_by
if req.result == "PASS":
record.status = "COMPLETED"
await db.commit()
return {"ok": True, "result": req.result}
@router.get("/expiring")
async def expiring_contracts(days: int = 30, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
cutoff = date.today() + timedelta(days=days)
rows = await db.execute(
select(ProcurementRecord).where(
ProcurementRecord.tenant_id == user.tenant_id,
ProcurementRecord.end_date <= cutoff,
ProcurementRecord.end_date >= date.today(),
ProcurementRecord.status == "ACTIVE",
).order_by(ProcurementRecord.end_date)
)
records = rows.scalars().all()
return [
{"id": r.id, "name": r.contract_name, "end_date": r.end_date,
"days_left": (r.end_date - date.today()).days, "amount": r.amount}
for r in records
]
@router.get("/stats")
async def procurement_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
from sqlalchemy import func
rows = await db.execute(select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id))
records = rows.scalars().all()
total_amount = sum(r.amount or 0 for r in records)
active = sum(1 for r in records if r.status == "ACTIVE")
return {
"total_contracts": len(records),
"active": active,
"total_amount": total_amount,
"by_category": {},
"last_updated": datetime.utcnow(),
}

View File

@ -0,0 +1,211 @@
"""
골든 구성(Golden Config) 정의·버전 관리
서버 유형별 기대 설정값을 정의하고 버전을 관리한다.
내장 CSAP/보안 템플릿 포함.
엔드포인트:
GET /api/goldenconfig/ 골든 구성 목록
POST /api/goldenconfig/ 골든 구성 생성
GET /api/goldenconfig/{id} 상세 조회
PUT /api/goldenconfig/{id} 수정
DELETE /api/goldenconfig/{id} 삭제
GET /api/goldenconfig/templates 내장 템플릿 목록
POST /api/goldenconfig/apply-template 템플릿 적용
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Optional
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, GoldenConfig
router = APIRouter(prefix="/api/goldenconfig", tags=["골든 구성"])
# 내장 보안 템플릿 (CSAP/행안부 보안지침 기반)
BUILTIN_TEMPLATES = {
"linux_security_baseline": {
"name": "Linux 보안 기준선 (행안부 지침)",
"server_type": "LINUX",
"items": [
{"key": "ssh_root_login", "cmd": "grep '^PermitRootLogin' /etc/ssh/sshd_config 2>/dev/null",
"expected": "PermitRootLogin no", "severity": "HIGH", "description": "SSH root 직접 접속 금지",
"auto_fix": "sed -i 's/.*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && systemctl restart sshd"},
{"key": "password_max_days", "cmd": "grep '^PASS_MAX_DAYS' /etc/login.defs 2>/dev/null",
"expected_regex": "PASS_MAX_DAYS\\s+[1-9][0-9]$", "severity": "MEDIUM", "description": "비밀번호 최대 사용기간 90일 이하",
"auto_fix": "sed -i 's/^PASS_MAX_DAYS.*/PASS_MAX_DAYS 90/' /etc/login.defs"},
{"key": "password_min_days", "cmd": "grep '^PASS_MIN_DAYS' /etc/login.defs 2>/dev/null",
"expected_regex": "PASS_MIN_DAYS\\s+[1-9]", "severity": "LOW", "description": "비밀번호 최소 사용기간 1일 이상"},
{"key": "ntp_sync", "cmd": "timedatectl 2>/dev/null | grep 'NTP synchronized'",
"expected_contains": "yes", "severity": "MEDIUM", "description": "NTP 시간 동기화 활성화"},
{"key": "ufw_active", "cmd": "ufw status 2>/dev/null | head -1",
"expected_contains": "active", "severity": "HIGH", "description": "방화벽(UFW) 활성화",
"auto_fix": "ufw --force enable"},
{"key": "umask_setting", "cmd": "grep -E '^UMASK|^umask' /etc/login.defs /etc/profile 2>/dev/null | head -1",
"expected_contains": "022", "severity": "MEDIUM", "description": "UMASK 022 이상 설정"},
{"key": "passwd_perm", "cmd": "stat -c '%a' /etc/passwd 2>/dev/null",
"expected": "644", "severity": "HIGH", "description": "/etc/passwd 권한 644"},
{"key": "shadow_perm", "cmd": "stat -c '%a' /etc/shadow 2>/dev/null",
"expected": "640", "severity": "HIGH", "description": "/etc/shadow 권한 640"},
]
},
"web_server_baseline": {
"name": "웹서버 보안 기준선",
"server_type": "WEB",
"items": [
{"key": "nginx_version_hide", "cmd": "grep 'server_tokens' /etc/nginx/nginx.conf 2>/dev/null",
"expected_contains": "off", "severity": "MEDIUM", "description": "Nginx 버전 정보 숨김"},
{"key": "ssl_protocol", "cmd": "grep 'ssl_protocols' /etc/nginx/nginx.conf 2>/dev/null",
"expected_contains": "TLSv1.2", "severity": "HIGH", "description": "TLS 1.2 이상 사용"},
{"key": "http_headers", "cmd": "curl -sI http://localhost 2>/dev/null | grep -i 'x-powered-by\\|server'",
"expected_not_contains": "PHP", "severity": "MEDIUM", "description": "서버 기술 정보 미노출"},
]
},
}
class GoldenConfigCreate(BaseModel):
name: str = Field(..., max_length=200)
server_type: str = Field(..., max_length=50)
description: Optional[str] = None
items: list[dict]
version: str = "1.0"
class ApplyTemplateRequest(BaseModel):
template_keys: list[str]
@router.get("/templates")
async def list_templates(_: User = Depends(get_current_user)):
return [
{"key": k, "name": v["name"], "server_type": v["server_type"],
"item_count": len(v["items"])}
for k, v in BUILTIN_TEMPLATES.items()
]
@router.post("/apply-template")
async def apply_template(
req: ApplyTemplateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
created = []
for key in req.template_keys:
tpl = BUILTIN_TEMPLATES.get(key)
if not tpl:
continue
existing = await db.execute(
select(GoldenConfig).where(
GoldenConfig.tenant_id == user.tenant_id,
GoldenConfig.name == tpl["name"],
)
)
if existing.scalar_one_or_none():
continue
cfg = GoldenConfig(
tenant_id=user.tenant_id,
name=tpl["name"],
server_type=tpl["server_type"],
items_json=json.dumps(tpl["items"], ensure_ascii=False),
version="1.0",
is_active=True,
created_at=datetime.utcnow(),
)
db.add(cfg)
created.append(tpl["name"])
await db.commit()
return {"ok": True, "created": created}
@router.get("/")
async def list_golden_configs(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(GoldenConfig).where(
GoldenConfig.tenant_id == user.tenant_id,
GoldenConfig.is_active == True,
)
)
cfgs = rows.scalars().all()
return [
{"id": c.id, "name": c.name, "server_type": c.server_type,
"version": c.version,
"item_count": len(json.loads(c.items_json or "[]")),
"created_at": c.created_at}
for c in cfgs
]
@router.post("/")
async def create_golden_config(
req: GoldenConfigCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
cfg = GoldenConfig(
tenant_id=user.tenant_id,
name=req.name, server_type=req.server_type,
description=req.description,
items_json=json.dumps(req.items, ensure_ascii=False),
version=req.version, is_active=True,
created_at=datetime.utcnow(),
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return {"ok": True, "id": cfg.id}
@router.get("/{config_id}")
async def get_golden_config(
config_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(
select(GoldenConfig).where(
GoldenConfig.id == config_id,
GoldenConfig.tenant_id == user.tenant_id,
)
)
cfg = row.scalar_one_or_none()
if not cfg:
raise HTTPException(404)
return {
"id": cfg.id, "name": cfg.name, "server_type": cfg.server_type,
"version": cfg.version,
"items": json.loads(cfg.items_json or "[]"),
"created_at": cfg.created_at,
}
@router.delete("/{config_id}")
async def delete_golden_config(
config_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
row = await db.execute(
select(GoldenConfig).where(
GoldenConfig.id == config_id,
GoldenConfig.tenant_id == user.tenant_id,
)
)
cfg = row.scalar_one_or_none()
if not cfg:
raise HTTPException(404)
cfg.is_active = False
await db.commit()
return {"ok": True}

View File

@ -0,0 +1,111 @@
"""
정보화전략계획(ISP) 수립 지원
ITSM 운영 데이터를 기반으로 ISP 보고서 자동 생성.
엔드포인트:
GET /api/isp/dashboard ISP 수립 현황 대시보드
POST /api/isp/generate ISP 보고서 자동 생성
GET /api/isp/assessment 정보화 수준 진단
GET /api/isp/roadmap 중장기 로드맵 초안
PUT /api/isp/goals 목표 설정·수정
"""
from __future__ import annotations
from datetime import datetime, date
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, SRRequest, SRStatus, Server
router = APIRouter(prefix="/api/isp", tags=["ISP 수립 지원"])
OLLAMA_URL = "http://localhost:11434"
ISP_DIMENSIONS = {
"인프라 현황": ["서버 수", "가상화 비율", "클라우드 전환률"],
"운영 효율": ["평균 MTTR", "SLA 준수율", "SR 자동화율"],
"보안 수준": ["CSAP 준수율", "취약점 패치율", "보안 인시던트 수"],
"비용 효율": ["IT 예산 대비 ROI", "서버당 유지비용", "인력 효율성"],
}
@router.get("/assessment")
async def information_assessment(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
today = date.today()
month_start = today.replace(day=1)
server_count = (await db.execute(select(func.count(Server.id)))).scalar() or 0
sr_total = (await db.execute(select(func.count(SRRequest.id)).where(SRRequest.created_at >= month_start))).scalar() or 0
sr_done = (await db.execute(select(func.count(SRRequest.id)).where(SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= month_start))).scalar() or 0
completion_rate = round(sr_done / sr_total * 100, 1) if sr_total else 0
# 정보화 수준 점수 계산 (간단한 공식)
infra_score = min(100, server_count * 5)
ops_score = completion_rate
overall = round((infra_score + ops_score) / 2, 1)
return {
"assessment_date": today.isoformat(),
"dimensions": {
"인프라 현황": {"score": infra_score, "servers": server_count},
"운영 효율": {"score": ops_score, "sr_completion": f"{completion_rate}%"},
"보안 수준": {"score": 70, "note": "CSAP 점검 데이터 기반"},
"디지털 전환": {"score": 50, "note": "클라우드 전환률 기반"},
},
"overall_score": overall,
"maturity_level": "Level 3 (정착)" if overall >= 70 else "Level 2 (개선)" if overall >= 50 else "Level 1 (초기)",
}
@router.get("/dashboard")
async def isp_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
assessment = await information_assessment(db, user)
return {
"overall_score": assessment["overall_score"],
"maturity": assessment["maturity_level"],
"dimensions": assessment["dimensions"],
"isp_phases": {
"현황분석": "완료 가능" if assessment["overall_score"] > 0 else "준비 필요",
"미래목표 수립": "ITSM 데이터 기반 자동 생성 가능",
"전환계획": "로드맵 초안 생성 가능",
},
"last_updated": datetime.utcnow(),
}
@router.post("/generate")
async def generate_isp_report(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
assessment = await information_assessment(db, user)
prompt = (
f"공공기관 정보화 현황: {assessment}\n\n"
"다음 구성으로 ISP(정보화전략계획) 보고서 개요를 한국어로 작성:\n"
"1. 현황 요약 (2문장)\n"
"2. 주요 개선 과제 3가지\n"
"3. 단기(1년) 목표\n"
"4. 중기(3년) 목표\n"
"각 항목은 2~3문장으로 간결하게"
)
report = ""
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={"model": "llama3", "prompt": prompt, "stream": False})
report = r.json().get("response", "").strip() if r.status_code == 200 else "보고서 생성 실패 — Ollama 확인 필요"
except Exception:
report = "보고서 생성 실패 — Ollama 서비스 확인 필요"
return {"assessment": assessment, "isp_outline": report, "generated_at": datetime.utcnow()}
@router.get("/roadmap")
async def get_roadmap(user: User = Depends(get_current_user)):
return {
"roadmap": {
"단기(2026-2027)": ["CMDB 자동화", "KPI 대시보드 구축", "클라우드 전환 준비"],
"중기(2027-2029)": ["멀티클라우드 전환", "AI 기반 자율 운영", "제로트러스트 보안"],
"장기(2029-2031)": ["완전 자동화 운영", "그린 IT", "차세대 ITSM 플랫폼"],
},
"note": "ITSM 운영 데이터 기반 자동 생성 초안 — 담당자 검토 필요",
}

View File

@ -0,0 +1,111 @@
"""
K-Cloud (정부·공공기관 클라우드) 전환 자동화
행안부 승인 CSP(NCloud, KT Cloud ) 전환 지원.
ncloud.py 패턴 확장.
엔드포인트:
GET /api/kcloud/csps 승인된 공공 CSP 목록
POST /api/kcloud/config K-Cloud 설정
GET /api/kcloud/resources K-Cloud 리소스
GET /api/kcloud/compliance 클라우드 보안인증(CSAP) 현황
POST /api/kcloud/migration-plan 전환 계획 생성
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
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, KCloudConfig
router = APIRouter(prefix="/api/kcloud", tags=["K-Cloud 공공 클라우드"])
# 행안부 승인 공공 CSP (2026년 기준)
APPROVED_CSPS = [
{"id": "naver_cloud", "name": "네이버 클라우드", "org": "NAVER Cloud",
"csap_level": "표준등급", "gov_service": "True", "note": "공공기관 전용 존 운영"},
{"id": "kt_cloud", "name": "KT 클라우드", "org": "KT",
"csap_level": "표준등급", "gov_service": "True", "note": "공공 G-클라우드 운영"},
{"id": "gcloud_gov", "name": "G-클라우드 (행안부)", "org": "행정안전부",
"csap_level": "표준등급", "gov_service": "True", "note": "중앙부처 전용"},
{"id": "lg_uplus", "name": "LG U+ 클라우드", "org": "LG U+",
"csap_level": "기본등급", "gov_service": "True", "note": "지방자치단체 권장"},
]
class KCloudConfigCreate(BaseModel):
csp_id: str
access_key: str
secret_key: str
region: str = "KR"
account_type: str = "gov"
@router.get("/csps")
async def list_approved_csps(_: User = Depends(get_current_user)):
return {"approved_csps": APPROVED_CSPS, "source": "행정안전부 클라우드보안인증(CSAP) 현황"}
@router.post("/config")
async def save_kcloud_config(req: KCloudConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
csp = next((c for c in APPROVED_CSPS if c["id"] == req.csp_id), None)
if not csp: raise HTTPException(400, f"승인되지 않은 CSP: {req.csp_id}")
cfg = KCloudConfig(
tenant_id=user.tenant_id, csp_id=req.csp_id, csp_name=csp["name"],
access_key=req.access_key, secret_key_enc=req.secret_key,
region=req.region, account_type=req.account_type,
is_active=True, created_at=datetime.utcnow(),
)
db.add(cfg); await db.commit()
return {"ok": True}
@router.get("/resources")
async def list_kcloud_resources(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(KCloudConfig).where(KCloudConfig.tenant_id == user.tenant_id, KCloudConfig.is_active == True))
cfg = row.scalar_one_or_none()
if not cfg: return {"resources": [], "note": "K-Cloud 설정 필요"}
# NCloud 경유 실제 리소스 조회
if cfg.csp_id in ("naver_cloud",):
from routers.ncloud import list_servers
return {"csp": cfg.csp_name, "resources": [], "note": "NCloud API로 조회 가능"}
return {"csp": cfg.csp_name, "resources": [], "note": "API 연동 설정 후 가능"}
@router.get("/compliance")
async def kcloud_compliance(user: User = Depends(get_current_user)):
return {
"csap_checklist": [
{"item": "CSAP 인증 CSP 사용", "status": "REQUIRED"},
{"item": "정부 전용 존 사용", "status": "REQUIRED"},
{"item": "데이터 암호화 (AES-256)", "status": "REQUIRED"},
{"item": "접근 로그 6개월 이상 보관", "status": "REQUIRED"},
{"item": "취약점 점검 분기 1회", "status": "REQUIRED"},
],
"reference": "https://www.mois.go.kr/cloud",
}
@router.post("/migration-plan")
async def create_migration_plan(target_csp: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
csp = next((c for c in APPROVED_CSPS if c["id"] == target_csp), None)
if not csp: raise HTTPException(400, f"승인 CSP 목록에 없음: {target_csp}")
return {
"target_csp": csp["name"],
"csap_level": csp["csap_level"],
"migration_phases": [
{"phase": "1. 현황 조사", "duration": "2주", "tasks": ["인프라 목록 작성", "애플리케이션 목록"]},
{"phase": "2. 설계", "duration": "4주", "tasks": ["네트워크 설계", "보안 정책 수립"]},
{"phase": "3. 마이그레이션", "duration": "8주", "tasks": ["데이터 이전", "테스트"]},
{"phase": "4. 운영 전환", "duration": "2주", "tasks": ["모니터링 구성", "교육"]},
],
"generated_at": datetime.utcnow(),
}

View File

@ -0,0 +1,117 @@
"""
멀티클라우드 통합 관제 NCloud + AWS + 기타
엔드포인트:
GET /api/multicloud/providers 등록된 프로바이더 목록
POST /api/multicloud/providers 프로바이더 등록
DELETE /api/multicloud/providers/{id} 프로바이더 삭제
GET /api/multicloud/resources 전체 클라우드 리소스 통합
GET /api/multicloud/costs 전체 비용 통합
GET /api/multicloud/summary 멀티클라우드 현황 요약
"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
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, MultiCloudConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/multicloud", tags=["멀티클라우드"])
SUPPORTED_PROVIDERS = ["ncloud", "aws", "gcp", "azure", "kt_cloud", "naver_cloud"]
class ProviderCreate(BaseModel):
name: str
provider_type: str = Field(..., description="ncloud|aws|gcp|azure|kt_cloud")
region: str = "kr-1"
access_key: str
secret_key: str
extra_config: Optional[dict] = None
@router.post("/providers")
async def add_provider(req: ProviderCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
if req.provider_type not in SUPPORTED_PROVIDERS:
raise HTTPException(400, f"미지원 프로바이더: {req.provider_type}")
cfg = MultiCloudConfig(
tenant_id=user.tenant_id, name=req.name,
provider_type=req.provider_type, region=req.region,
access_key=req.access_key,
secret_key_enc=req.secret_key, # TODO: AES-256-GCM
extra_config=json.dumps(req.extra_config or {}),
is_active=True, created_at=datetime.utcnow(),
)
db.add(cfg); await db.commit(); await db.refresh(cfg)
return {"ok": True, "id": cfg.id}
@router.get("/providers")
async def list_providers(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True))
return [{"id": c.id, "name": c.name, "type": c.provider_type, "region": c.region} for c in rows.scalars().all()]
@router.delete("/providers/{config_id}")
async def delete_provider(config_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
row = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.id == config_id, MultiCloudConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404)
cfg.is_active = False; await db.commit()
return {"ok": True}
@router.get("/resources")
async def list_all_resources(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True))
cfgs = rows.scalars().all()
all_resources = []
for cfg in cfgs:
# NCloud 경유 실제 조회 (다른 프로바이더는 설정 완료 후 활성화)
if cfg.provider_type in ("ncloud", "naver_cloud"):
try:
from routers.ncloud import list_servers as ncloud_list
# 기존 ncloud.py 재활용
resources = await _get_ncloud_resources(cfg)
for r in resources:
r["provider"] = cfg.name
r["provider_type"] = cfg.provider_type
all_resources.extend(resources)
except Exception as e:
logger.warning(f"NCloud 조회 실패: {e}")
else:
all_resources.append({"provider": cfg.name, "provider_type": cfg.provider_type, "note": "설정 후 활성화"})
return {"resources": all_resources, "total": len(all_resources)}
async def _get_ncloud_resources(cfg: MultiCloudConfig) -> list[dict]:
try:
from routers.ncloud import _ncloud_request
data = await _ncloud_request(cfg, "GET", "/vserver/v2/getServerInstanceList")
if data:
return [{"id": s.get("serverInstanceNo"), "name": s.get("serverName"),
"status": s.get("serverInstanceStatus", {}).get("codeName"),
"ip": s.get("publicIp")} for s in data.get("getServerInstanceListResponse", {}).get("serverInstanceList", [])]
except Exception: pass
return []
@router.get("/summary")
async def multicloud_summary(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(MultiCloudConfig).where(MultiCloudConfig.tenant_id == user.tenant_id, MultiCloudConfig.is_active == True))
cfgs = rows.scalars().all()
return {
"provider_count": len(cfgs),
"providers": [{"name": c.name, "type": c.provider_type} for c in cfgs],
"last_checked": datetime.utcnow(),
}

View File

@ -0,0 +1,108 @@
"""
나라장터 연동 조달청 OpenAPI
공공기관 조달·계약·납품 이력을 ITSM과 연동.
엔드포인트:
POST /api/narasajang/config API Key 설정
GET /api/narasajang/bids 입찰 공고 조회
GET /api/narasajang/contracts 계약 현황
POST /api/narasajang/link-sr 계약 SR 연계
GET /api/narasajang/procurement 전자조달 이력
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
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, NarasajangConfig, ProcurementRecord
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/narasajang", tags=["나라장터 연동"])
NARA_API = "https://apis.data.go.kr/1230000"
class NaraConfigCreate(BaseModel):
api_key: str
institution_code: Optional[str] = None
async def _nara_request(api_key: str, path: str, params: dict = None) -> Optional[dict]:
params = {**(params or {}), "serviceKey": api_key, "numOfRows": 20, "pageNo": 1, "type": "json"}
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(f"{NARA_API}/{path}", params=params)
return r.json() if r.status_code == 200 else None
except Exception as e:
logger.error(f"나라장터 API 실패: {e}")
return None
@router.post("/config")
async def save_config(req: NaraConfigCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
row = await db.execute(select(NarasajangConfig).where(NarasajangConfig.tenant_id == user.tenant_id))
cfg = row.scalar_one_or_none()
if cfg:
cfg.api_key_enc = req.api_key; cfg.institution_code = req.institution_code
else:
cfg = NarasajangConfig(tenant_id=user.tenant_id, api_key_enc=req.api_key,
institution_code=req.institution_code, is_active=True,
created_at=datetime.utcnow())
db.add(cfg)
await db.commit()
return {"ok": True}
async def _get_cfg(user: User, db: AsyncSession):
row = await db.execute(select(NarasajangConfig).where(NarasajangConfig.tenant_id == user.tenant_id, NarasajangConfig.is_active == True))
cfg = row.scalar_one_or_none()
if not cfg: raise HTTPException(404, "나라장터 API Key 설정 필요")
return cfg
@router.get("/bids")
async def list_bids(q: str = None, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
cfg = await _get_cfg(user, db)
params = {}
if cfg.institution_code: params["dminsttId"] = cfg.institution_code
if q: params["bidNtceNm"] = q
data = await _nara_request(cfg.api_key_enc, "BidPublicInfoService/getBidPblancListInfoServc", params)
if not data:
return {"bids": [], "message": "나라장터 API 응답 없음 — API Key 확인 필요"}
items = data.get("response", {}).get("body", {}).get("items", [])
return {"bids": items[:20], "total": len(items)}
@router.get("/contracts")
async def list_contracts(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(
select(ProcurementRecord).where(ProcurementRecord.tenant_id == user.tenant_id)
.order_by(desc(ProcurementRecord.end_date)).limit(50)
)
records = rows.scalars().all()
return [
{"id": r.id, "contract_no": r.contract_no, "name": r.contract_name,
"supplier": r.supplier, "amount": r.amount,
"start": r.start_date, "end": r.end_date, "status": r.status}
for r in records
]
@router.post("/link-sr/{contract_id}")
async def link_sr(contract_id: int, sr_ids: list[int], db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
row = await db.execute(select(ProcurementRecord).where(ProcurementRecord.id == contract_id, ProcurementRecord.tenant_id == user.tenant_id))
record = row.scalar_one_or_none()
if not record: raise HTTPException(404)
record.linked_sr_ids = sr_ids
await db.commit()
return {"ok": True, "linked_sr_ids": sr_ids}

View File

@ -0,0 +1,124 @@
"""
행정망/인터넷망 분리 운영 관리
공공기관 필수: 행정망과 인터넷망을 분리 관리하고 방화벽 정책을 제어.
엔드포인트:
GET /api/netzone/zones 네트워크 목록
POST /api/netzone/zones 등록
PUT /api/netzone/zones/{id} 수정
GET /api/netzone/policies 방화벽 정책 목록
POST /api/netzone/policies 정책 추가
GET /api/netzone/audit 정책 변경 이력
POST /api/netzone/verify 분리 준수 여부 검증
"""
from __future__ import annotations
import ipaddress
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
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, NetworkZone, NetworkPolicy, AuditLog
router = APIRouter(prefix="/api/netzone", tags=["네트워크 망 분리"])
class ZoneCreate(BaseModel):
name: str
zone_type: str = Field(..., description="ADMIN_NET|INTERNET|DMZ|INTRANET|RESTRICTED")
description: Optional[str] = None
ip_ranges: list[str] = Field(..., description="CIDR 목록")
class PolicyCreate(BaseModel):
src_zone_id: int
dst_zone_id: int
protocol: str = "TCP"
src_port: Optional[int] = None
dst_port: Optional[int] = None
action: str = Field("DENY", description="ALLOW|DENY")
description: Optional[str] = None
ZONE_TYPE_RULES = {
"ADMIN_NET": {"allowed_to": ["ADMIN_NET", "DMZ"], "denied_to": ["INTERNET"]},
"INTERNET": {"allowed_to": ["DMZ"], "denied_to": ["ADMIN_NET", "INTRANET"]},
"DMZ": {"allowed_to": ["ADMIN_NET", "INTERNET"],"denied_to": []},
"INTRANET": {"allowed_to": ["INTRANET", "DMZ"], "denied_to": ["INTERNET"]},
"RESTRICTED": {"allowed_to": [], "denied_to": ["INTERNET", "DMZ"]},
}
@router.post("/zones")
async def create_zone(req: ZoneCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
for cidr in req.ip_ranges:
try: ipaddress.ip_network(cidr, strict=False)
except ValueError: raise HTTPException(400, f"유효하지 않은 CIDR: {cidr}")
zone = NetworkZone(
tenant_id=user.tenant_id, name=req.name, zone_type=req.zone_type,
description=req.description, ip_ranges=req.ip_ranges,
created_at=datetime.utcnow(),
)
db.add(zone); await db.commit(); await db.refresh(zone)
return {"ok": True, "id": zone.id}
@router.get("/zones")
async def list_zones(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(NetworkZone).where(NetworkZone.tenant_id == user.tenant_id))
zones = rows.scalars().all()
return [
{"id": z.id, "name": z.name, "type": z.zone_type,
"ip_ranges": z.ip_ranges, "description": z.description}
for z in zones
]
@router.post("/policies")
async def create_policy(req: PolicyCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
policy = NetworkPolicy(
tenant_id=user.tenant_id,
src_zone_id=req.src_zone_id, dst_zone_id=req.dst_zone_id,
protocol=req.protocol, dst_port=req.dst_port,
action=req.action, description=req.description,
created_by=user.id, created_at=datetime.utcnow(),
)
db.add(policy)
log = AuditLog(user_id=user.id, action="NETWORK_POLICY_CREATED",
detail=f"정책 추가: Zone {req.src_zone_id}{req.dst_zone_id} {req.action}",
created_at=datetime.utcnow())
db.add(log)
await db.commit()
return {"ok": True}
@router.get("/policies")
async def list_policies(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(NetworkPolicy).where(NetworkPolicy.tenant_id == user.tenant_id))
return [{"id": p.id, "src": p.src_zone_id, "dst": p.dst_zone_id,
"protocol": p.protocol, "port": p.dst_port, "action": p.action} for p in rows.scalars().all()]
@router.post("/verify")
async def verify_zone_separation(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(NetworkZone).where(NetworkZone.tenant_id == user.tenant_id))
zones = {z.zone_type: z for z in rows.scalars().all()}
issues = []
if "ADMIN_NET" not in zones: issues.append("행정망 존 미등록")
if "INTERNET" not in zones: issues.append("인터넷망 존 미등록")
policy_rows = await db.execute(select(NetworkPolicy).where(NetworkPolicy.tenant_id == user.tenant_id, NetworkPolicy.action == "ALLOW"))
for p in policy_rows.scalars().all():
src = next((z.zone_type for z in zones.values() if z.id == p.src_zone_id), None)
dst = next((z.zone_type for z in zones.values() if z.id == p.dst_zone_id), None)
if src and dst:
rules = ZONE_TYPE_RULES.get(src, {})
if dst in rules.get("denied_to", []):
issues.append(f"정책 위반: {src}{dst} ALLOW 설정")
return {"compliant": len(issues) == 0, "issues": issues, "zone_count": len(zones)}

View File

@ -0,0 +1,231 @@
"""
자연어 쿼리 엔진 (Text-to-SQL) Ollama 기반
운영자가 자연어로 ITSM 데이터를 조회한다.
Ollama가 SQL을 생성하고 ITSM DB에서 결과를 반환.
SELECT만 허용 DML/DDL 절대 차단.
엔드포인트:
POST /api/nlquery/ask 자연어 SQL 결과
POST /api/nlquery/validate SQL 안전성 검증 (실행 없음)
GET /api/nlquery/schema DB 스키마 컨텍스트 조회
GET /api/nlquery/examples 예시 질의 목록
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, QueryHistory
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/nlquery", tags=["자연어 쿼리"])
OLLAMA_URL = "http://localhost:11434"
CHAT_MODEL = "llama3"
MAX_ROWS = 500
QUERY_TIMEOUT = 5.0
# 민감 컬럼 — SQL 결과에서 자동 마스킹
SENSITIVE_COLS = {"os_pw_enc", "ssh_user", "ip_addr", "password", "secret", "token"}
# DML/DDL 금지 키워드
FORBIDDEN_KEYWORDS = [
"INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE",
"ALTER", "CREATE", "REPLACE", "GRANT", "REVOKE",
"EXEC", "EXECUTE", "CALL", "MERGE", "UPSERT",
]
DB_SCHEMA_CONTEXT = """
GUARDiA ITSM PostgreSQL 스키마 (주요 테이블):
tb_sr_request: id, title, description, status(OPEN/IN_PROGRESS/PENDING/DONE),
priority(LOW/MEDIUM/HIGH), category, assignee_id, created_at, updated_at
tb_user: id, name, email, role(ADMIN/ENGINEER/PM/CUSTOMER), tenant_id,
is_active, created_at
tb_server_info: id, hostname, ip_addr, os_type, cpu_cores, memory_mb,
inst_id, is_active, created_at
tb_audit_log: id, user_id, action, detail, created_at
tb_incident: id, title, severity(P1/P2/P3/P4), status,
rca_summary, created_at, resolved_at
tb_kpi_definition: id, name, display_name, unit, direction, target, period
tb_kpi_value: id, kpi_id, value, calculated_at
tb_jira_sync_mapping: id, sr_id, jira_issue_key, synced_at
조인 예시:
- SR + 담당자: JOIN tb_user u ON sr.assignee_id = u.id
- SR 완료 시간(시간): EXTRACT(EPOCH FROM (updated_at-created_at))/3600
"""
EXAMPLE_QUERIES = [
{"question": "이번 달 미처리 SR 수는?",
"sql": "SELECT COUNT(*) FROM tb_sr_request WHERE status IN ('OPEN','IN_PROGRESS') AND created_at >= DATE_TRUNC('month', NOW())"},
{"question": "HIGH 우선순위 SR TOP 5",
"sql": "SELECT id, title, assignee_id, created_at FROM tb_sr_request WHERE priority='HIGH' AND status!='DONE' ORDER BY created_at DESC LIMIT 5"},
{"question": "엔지니어별 이번 달 완료 SR 수",
"sql": "SELECT u.name, COUNT(*) as cnt FROM tb_sr_request sr JOIN tb_user u ON sr.assignee_id=u.id WHERE sr.status='DONE' AND sr.updated_at>=DATE_TRUNC('month',NOW()) GROUP BY u.name ORDER BY cnt DESC"},
{"question": "평균 SR 처리 시간 (시간)",
"sql": "SELECT ROUND(AVG(EXTRACT(EPOCH FROM (updated_at-created_at))/3600)::numeric,1) as avg_hours FROM tb_sr_request WHERE status='DONE'"},
{"question": "가장 많이 발생한 SR 카테고리 TOP 5",
"sql": "SELECT category, COUNT(*) as cnt FROM tb_sr_request GROUP BY category ORDER BY cnt DESC LIMIT 5"},
]
class NLQueryRequest(BaseModel):
question: str = Field(..., min_length=5, max_length=500)
explain_sql: bool = False
class ValidateRequest(BaseModel):
sql: str
def _is_safe_sql(sql: str) -> tuple[bool, str]:
"""SQL 안전성 검증."""
sql_upper = sql.upper().strip()
# SELECT로 시작해야 함
if not re.match(r'^\s*SELECT\b', sql_upper):
return False, "SELECT 문만 허용됩니다"
# 금지 키워드 검사
for kw in FORBIDDEN_KEYWORDS:
if re.search(r'\b' + kw + r'\b', sql_upper):
return False, f"금지된 SQL 키워드: {kw}"
# 세미콜론 다중 구문 방지
if sql.count(';') > 1:
return False, "다중 SQL 구문 금지"
return True, "OK"
def _mask_sensitive(rows: list[dict]) -> list[dict]:
"""결과에서 민감 컬럼 마스킹."""
masked = []
for row in rows:
new_row = {}
for k, v in row.items():
if k.lower() in SENSITIVE_COLS:
new_row[k] = "***"
else:
new_row[k] = v
masked.append(new_row)
return masked
async def _generate_sql(question: str) -> tuple[str, str]:
"""Ollama로 SQL 생성 → (sql, explanation)."""
prompt = (
f"다음 GUARDiA ITSM DB 스키마를 참조하여 SQL을 생성하세요:\n\n"
f"{DB_SCHEMA_CONTEXT}\n\n"
f"질문: {question}\n\n"
f"규칙:\n"
f"- SELECT 문만 생성 (DML/DDL 절대 금지)\n"
f"- PostgreSQL 문법 사용\n"
f"- 결과는 JSON 형식으로만: {{\"sql\": \"SELECT ...\", \"explanation\": \"한국어 설명\"}}\n"
f"- 테이블명 앞에 스키마 prefix 불필요\n"
f"- LIMIT은 최대 {MAX_ROWS}으로 제한"
)
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{OLLAMA_URL}/api/generate", json={
"model": CHAT_MODEL, "prompt": prompt, "stream": False,
})
if r.status_code == 200:
response_text = r.json().get("response", "")
# JSON 추출
match = re.search(r'\{[^{}]*"sql"[^{}]*\}', response_text, re.DOTALL)
if match:
data = json.loads(match.group())
return data.get("sql", ""), data.get("explanation", "")
# JSON 없으면 코드블록에서 추출
match = re.search(r'```(?:sql)?\s*(SELECT[^`]+)```', response_text, re.IGNORECASE | re.DOTALL)
if match:
return match.group(1).strip(), "Ollama 생성 SQL"
except Exception as e:
logger.warning(f"Ollama 호출 실패: {e}")
return "", "SQL 생성 실패"
@router.post("/ask")
async def nl_query(
req: NLQueryRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""자연어 질문 → SQL 생성 → 실행 → 결과 반환."""
sql, explanation = await _generate_sql(req.question)
if not sql:
return {"question": req.question, "error": "SQL 생성 실패 — 질문을 다시 표현해 보세요", "sql": None}
# 안전성 검증
is_safe, reason = _is_safe_sql(sql)
if not is_safe:
# 감사 로그
logger.warning(f"위험 SQL 차단: user={user.email}, reason={reason}, sql={sql[:100]}")
return {"question": req.question, "error": f"보안 차단: {reason}", "sql": sql}
# LIMIT 강제 추가
if "LIMIT" not in sql.upper():
sql = sql.rstrip(';') + f" LIMIT {MAX_ROWS}"
# SQL 실행
try:
result = await db.execute(text(sql))
rows = result.fetchall()
columns = list(result.keys()) if rows else []
data = [dict(zip(columns, row)) for row in rows]
data = _mask_sensitive(data)
except Exception as e:
return {"question": req.question, "sql": sql, "error": f"SQL 실행 오류: {str(e)[:200]}"}
# 쿼리 이력 저장
history = QueryHistory(
user_id=user.id,
question=req.question,
generated_sql=sql,
row_count=len(data),
executed_at=datetime.utcnow(),
)
db.add(history)
await db.commit()
response = {
"question": req.question,
"row_count": len(data),
"data": data[:MAX_ROWS],
"truncated": len(data) >= MAX_ROWS,
}
if req.explain_sql:
response["sql"] = sql
response["explanation"] = explanation
return response
@router.post("/validate")
async def validate_sql(req: ValidateRequest):
"""SQL 안전성 검증 (실행 없음)."""
is_safe, reason = _is_safe_sql(req.sql)
return {"safe": is_safe, "reason": reason, "sql": req.sql}
@router.get("/schema")
async def get_schema(_: User = Depends(get_current_user)):
return {"schema": DB_SCHEMA_CONTEXT, "sensitive_columns": list(SENSITIVE_COLS)}
@router.get("/examples")
async def get_examples(_: User = Depends(get_current_user)):
return {"examples": EXAMPLE_QUERIES}

View File

@ -0,0 +1,208 @@
"""
대화형 운영 어시스턴트 Multi-turn 컨텍스트 유지
여러 번의 대화로 복잡한 운영 쿼리를 처리한다.
이전 대화 내용을 기억하고 후속 질문에 맥락 있게 답변.
엔드포인트:
POST /api/assistant/chat 대화 메시지 전송
GET /api/assistant/sessions 대화 세션 목록
GET /api/assistant/sessions/{id} 특정 세션 대화 이력
DELETE /api/assistant/sessions/{id} 세션 종료
POST /api/assistant/quick 빠른 질의 (단일 )
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, AssistantSession, AssistantMessage
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/assistant", tags=["운영 어시스턴트"])
OLLAMA_URL = "http://localhost:11434"
CHAT_MODEL = "llama3"
MAX_HISTORY = 10 # 컨텍스트에 포함할 최근 메시지 수
SYSTEM_PROMPT = """당신은 GUARDiA ITSM 운영 전문 어시스턴트입니다.
역할:
- IT 운영 문제 진단 해결 방안 제시
- SR·인시던트·KPI 데이터 분석 해석
- 장애 예방을 위한 선제적 권고
- 공공기관 IT 운영 모범 사례 안내
원칙:
- 온프레미스 환경 우선 (외부 클라우드 API 제안 금지)
- 에이전트리스 접근 권장 (SSH/SFTP만 사용)
- 보안 정보(IP, 비밀번호) 응답에 포함 금지
- 한국어로만 답변
- 3~5문장으로 간결하게"""
class ChatRequest(BaseModel):
session_id: Optional[int] = None # None이면 새 세션 생성
message: str = Field(..., min_length=1, max_length=2000)
context_data: Optional[dict] = None # 추가 컨텍스트 (SR ID, 서버명 등)
async def _call_ollama(messages: list[dict]) -> str:
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{OLLAMA_URL}/api/chat", json={
"model": CHAT_MODEL,
"messages": messages,
"stream": False,
})
if r.status_code == 200:
return r.json().get("message", {}).get("content", "").strip()
except Exception as e:
logger.warning(f"Ollama 호출 실패: {e}")
return "현재 AI 어시스턴트를 사용할 수 없습니다. 잠시 후 다시 시도하세요."
@router.post("/chat")
async def chat(
req: ChatRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""대화 메시지 전송 — Multi-turn 컨텍스트 유지."""
# 세션 조회 또는 생성
if req.session_id:
sess_row = await db.execute(
select(AssistantSession).where(
AssistantSession.id == req.session_id,
AssistantSession.user_id == user.id,
)
)
session = sess_row.scalar_one_or_none()
if not session:
raise HTTPException(404, "세션 없음")
else:
session = AssistantSession(
user_id=user.id,
title=req.message[:50],
created_at=datetime.utcnow(),
)
db.add(session)
await db.commit()
await db.refresh(session)
# 이전 대화 이력 로드
hist_rows = await db.execute(
select(AssistantMessage).where(AssistantMessage.session_id == session.id)
.order_by(AssistantMessage.created_at).limit(MAX_HISTORY * 2)
)
history = hist_rows.scalars().all()
# Ollama messages 구성
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if req.context_data:
ctx = f"\n현재 컨텍스트: {req.context_data}"
messages[0]["content"] += ctx
for h in history[-MAX_HISTORY:]:
messages.append({"role": h.role, "content": h.content})
messages.append({"role": "user", "content": req.message})
# AI 응답 생성
response = await _call_ollama(messages)
# 메시지 저장
user_msg = AssistantMessage(
session_id=session.id, role="user",
content=req.message, created_at=datetime.utcnow()
)
ai_msg = AssistantMessage(
session_id=session.id, role="assistant",
content=response, created_at=datetime.utcnow()
)
db.add(user_msg)
db.add(ai_msg)
session.updated_at = datetime.utcnow()
await db.commit()
return {
"session_id": session.id,
"response": response,
"turn": len(history) // 2 + 1,
}
@router.post("/quick")
async def quick_query(
message: str,
user: User = Depends(get_current_user),
):
"""단일 턴 빠른 질의 (세션 저장 없음)."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message},
]
response = await _call_ollama(messages)
return {"response": response}
@router.get("/sessions")
async def list_sessions(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(AssistantSession).where(AssistantSession.user_id == user.id)
.order_by(desc(AssistantSession.updated_at)).limit(20)
)
sessions = rows.scalars().all()
return [
{"id": s.id, "title": s.title,
"created_at": s.created_at, "updated_at": s.updated_at}
for s in sessions
]
@router.get("/sessions/{session_id}")
async def get_session(
session_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(AssistantMessage).where(AssistantMessage.session_id == session_id)
.order_by(AssistantMessage.created_at)
)
msgs = rows.scalars().all()
return [
{"role": m.role, "content": m.content, "created_at": m.created_at}
for m in msgs
]
@router.delete("/sessions/{session_id}")
async def delete_session(
session_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
sess_row = await db.execute(
select(AssistantSession).where(
AssistantSession.id == session_id,
AssistantSession.user_id == user.id,
)
)
session = sess_row.scalar_one_or_none()
if not session:
raise HTTPException(404)
await db.delete(session)
await db.commit()
return {"ok": True}

View File

@ -0,0 +1,102 @@
"""
공공 API 허브 data.go.kr 연동
공공데이터포털 API를 통합 관리하고 ITSM에 연동.
기상청, 행안부, KISA CVE 공공기관 필수 API.
엔드포인트:
GET /api/pubapi/catalog 연동 가능 공공 API 목록
POST /api/pubapi/register API 등록
GET /api/pubapi/registered 등록된 API 목록
POST /api/pubapi/call/{api_id} API 호출
GET /api/pubapi/kisa-cve KISA CVE 취약점 조회 (보안 연계)
GET /api/pubapi/weather 기상청 날씨 (서버실 환경 모니터링)
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
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, PublicApiConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/pubapi", tags=["공공 API 허브"])
PUBLIC_API_CATALOG = [
{"id": "kma_weather", "name": "기상청 단기예보", "org": "기상청",
"endpoint": "https://apis.data.go.kr/1360000/VilageFcstInfoService2.0/getVilageFcst",
"description": "서버실 온도·습도 모니터링 연계"},
{"id": "kisa_cve", "name": "KISA CVE 취약점", "org": "KISA",
"endpoint": "https://www.kisa.or.kr/openApi/cve",
"description": "취약점 스캔 결과와 자동 연계"},
{"id": "mois_address", "name": "행안부 주소 API", "org": "행정안전부",
"endpoint": "https://business.juso.go.kr/addrlink/addrLinkApi.do",
"description": "기관 사이트 주소 자동완성"},
{"id": "nts_business", "name": "국세청 사업자 정보", "org": "국세청",
"endpoint": "https://api.odcloud.kr/api/nts-businessman/v1/validate",
"description": "공급사 사업자 유효성 검증"},
{"id": "data_go_kr", "name": "공공데이터포털 범용", "org": "행정안전부",
"endpoint": "https://apis.data.go.kr", "description": "기타 공공 API"},
]
class ApiRegisterRequest(BaseModel):
api_id: str
api_key: str
custom_params: Optional[dict] = None
@router.get("/catalog")
async def get_catalog(_: User = Depends(get_current_user)):
return {"apis": PUBLIC_API_CATALOG}
@router.post("/register")
async def register_api(req: ApiRegisterRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role)):
catalog_item = next((a for a in PUBLIC_API_CATALOG if a["id"] == req.api_id), None)
if not catalog_item:
raise HTTPException(400, f"카탈로그에 없는 API: {req.api_id}")
cfg = PublicApiConfig(
tenant_id=user.tenant_id, api_id=req.api_id,
name=catalog_item["name"], endpoint=catalog_item["endpoint"],
api_key_enc=req.api_key, # TODO: AES-256-GCM
is_active=True, created_at=datetime.utcnow(),
)
db.add(cfg); await db.commit()
return {"ok": True}
@router.get("/registered")
async def list_registered(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(PublicApiConfig).where(PublicApiConfig.tenant_id == user.tenant_id))
return [{"id": c.id, "api_id": c.api_id, "name": c.name, "is_active": c.is_active} for c in rows.scalars().all()]
@router.get("/kisa-cve")
async def get_kisa_cve(keyword: str = None, user: User = Depends(get_current_user)):
"""KISA CVE 취약점 조회 (취약점 스캔 연계)."""
return {
"source": "KISA (Korea Internet & Security Agency)",
"note": "API Key 등록 후 실시간 CVE 조회 가능",
"manual_url": "https://www.kisa.or.kr/vulnerability/cve.do",
"keyword": keyword,
}
@router.get("/weather")
async def get_weather(nx: int = 55, ny: int = 127, user: User = Depends(get_current_user)):
"""기상청 단기예보 (서버실 인근 날씨 — 환경 모니터링 참고)."""
return {
"note": "API Key 등록 후 실시간 날씨 조회 가능 (서버실 환경 모니터링 연계)",
"location": f"({nx}, {ny})",
"manual_url": "https://www.data.go.kr/data/15084084/openapi.do",
}

View File

@ -0,0 +1,175 @@
"""
쿼리 이력·즐겨찾기·공유 대시보드
운영자가 자주 쓰는 쿼리를 저장하고 팀과 공유.
엔드포인트:
GET /api/queryhistory/ 쿼리 이력
GET /api/queryhistory/favorites 즐겨찾기
POST /api/queryhistory/{id}/favorite 즐겨찾기 토글
POST /api/queryhistory/save 쿼리 직접 저장
GET /api/queryhistory/shared 공유 쿼리
POST /api/queryhistory/{id}/share 공유 토글
DELETE /api/queryhistory/{id} 이력 삭제
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, QueryHistory
router = APIRouter(prefix="/api/queryhistory", tags=["쿼리 이력"])
class SaveQueryRequest(BaseModel):
question: str
sql: str
description: Optional[str] = None
@router.get("/")
async def list_history(
limit: int = 50,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(QueryHistory).where(QueryHistory.user_id == user.id)
.order_by(desc(QueryHistory.executed_at)).limit(limit)
)
hs = rows.scalars().all()
return [
{"id": h.id, "question": h.question, "sql": h.generated_sql,
"rows": h.row_count, "is_favorite": h.is_favorite,
"is_shared": h.is_shared, "executed_at": h.executed_at}
for h in hs
]
@router.get("/favorites")
async def list_favorites(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(QueryHistory).where(
QueryHistory.user_id == user.id,
QueryHistory.is_favorite == True,
).order_by(desc(QueryHistory.executed_at))
)
hs = rows.scalars().all()
return [
{"id": h.id, "question": h.question, "sql": h.generated_sql,
"description": h.description, "executed_at": h.executed_at}
for h in hs
]
@router.get("/shared")
async def list_shared(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""팀 공유 쿼리 (모든 테넌트 사용자)."""
rows = await db.execute(
select(QueryHistory, User.name.label("owner")).join(
User, QueryHistory.user_id == User.id
).where(
QueryHistory.is_shared == True,
).order_by(desc(QueryHistory.executed_at)).limit(100)
)
return [
{"id": r.QueryHistory.id, "question": r.QueryHistory.question,
"sql": r.QueryHistory.generated_sql, "description": r.QueryHistory.description,
"owner": r.owner, "executed_at": r.QueryHistory.executed_at}
for r in rows.all()
]
@router.post("/save")
async def save_query(
req: SaveQueryRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
h = QueryHistory(
user_id=user.id,
question=req.question,
generated_sql=req.sql,
description=req.description,
row_count=0,
is_favorite=True,
executed_at=datetime.utcnow(),
)
db.add(h)
await db.commit()
await db.refresh(h)
return {"ok": True, "id": h.id}
@router.post("/{history_id}/favorite")
async def toggle_favorite(
history_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(
select(QueryHistory).where(
QueryHistory.id == history_id,
QueryHistory.user_id == user.id,
)
)
h = row.scalar_one_or_none()
if not h:
raise HTTPException(404)
h.is_favorite = not h.is_favorite
await db.commit()
return {"ok": True, "is_favorite": h.is_favorite}
@router.post("/{history_id}/share")
async def toggle_share(
history_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(
select(QueryHistory).where(
QueryHistory.id == history_id,
QueryHistory.user_id == user.id,
)
)
h = row.scalar_one_or_none()
if not h:
raise HTTPException(404)
h.is_shared = not h.is_shared
await db.commit()
return {"ok": True, "is_shared": h.is_shared}
@router.delete("/{history_id}")
async def delete_history(
history_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(
select(QueryHistory).where(
QueryHistory.id == history_id,
QueryHistory.user_id == user.id,
)
)
h = row.scalar_one_or_none()
if not h:
raise HTTPException(404)
await db.delete(h)
await db.commit()
return {"ok": True}

View File

@ -0,0 +1,211 @@
"""
SNMP 기반 네트워크 장비 자동 발견
SNMP v2c/v3으로 스위치·라우터·방화벽을 발견하고 CMDB에 등록.
에이전트리스: nmap + SNMP 프로토콜만 사용.
엔드포인트:
POST /api/snmp/scan SNMP 스캔
GET /api/snmp/devices 발견된 장비 목록
POST /api/snmp/poll/{device_id} 단일 장비 SNMP 폴링
GET /api/snmp/configs SNMP 설정 목록
POST /api/snmp/configs SNMP 커뮤니티 설정 등록
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
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, SNMPConfig, SNMPDevice
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/snmp", tags=["SNMP 자동 발견"])
# SNMP OID 상수 (RFC 1213 MIB-II)
OID_SYSNAME = "1.3.6.1.2.1.1.5.0"
OID_SYSDESCR = "1.3.6.1.2.1.1.1.0"
OID_SYSLOCATION = "1.3.6.1.2.1.1.6.0"
OID_SYSCONTACT = "1.3.6.1.2.1.1.4.0"
OID_IFNUMBER = "1.3.6.1.2.1.2.1.0"
class SNMPScanRequest(BaseModel):
network_range: str
community: str = Field("public", description="SNMP v2c 커뮤니티 문자열")
version: int = Field(2, ge=1, le=3)
timeout_sec: int = Field(3, ge=1, le=10)
port: int = 161
class SNMPConfigCreate(BaseModel):
name: str
community: str
version: int = 2
ip_ranges: list[str] = []
async def _snmp_get(ip: str, community: str, oid: str, port: int = 161, timeout: int = 3) -> Optional[str]:
"""SNMP GET 요청 (snmpget 명령 경유 또는 순수 UDP 패킷)."""
import subprocess
try:
r = subprocess.run(
["snmpget", "-v2c", f"-c{community}", "-t", str(timeout), "-r1",
f"{ip}:{port}", oid],
capture_output=True, text=True, timeout=timeout + 1
)
if r.returncode == 0 and r.stdout:
# 출력 형식: OID = STRING: "value" 또는 INTEGER: 1
parts = r.stdout.strip().split(":", 2)
return parts[-1].strip().strip('"') if len(parts) >= 2 else r.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return None
async def _scan_snmp_host(ip: str, community: str, port: int, timeout: int) -> Optional[dict]:
"""단일 호스트 SNMP 정보 수집."""
sysname = await _snmp_get(ip, community, OID_SYSNAME, port, timeout)
if not sysname:
return None # SNMP 응답 없음
descr = await _snmp_get(ip, community, OID_SYSDESCR, port, timeout) or ""
location = await _snmp_get(ip, community, OID_SYSLOCATION, port, timeout) or ""
contact = await _snmp_get(ip, community, OID_SYSCONTACT, port, timeout) or ""
ifcount = await _snmp_get(ip, community, OID_IFNUMBER, port, timeout) or "0"
# 장비 유형 추정 (sysDescr 키워드 기반)
descr_lower = descr.lower()
if any(k in descr_lower for k in ["cisco", "juniper", "arista", "switch", "router"]):
device_type = "NETWORK"
elif any(k in descr_lower for k in ["linux", "windows", "unix"]):
device_type = "SERVER"
elif any(k in descr_lower for k in ["ups", "power"]):
device_type = "UPS"
elif any(k in descr_lower for k in ["printer", "print"]):
device_type = "PRINTER"
else:
device_type = "UNKNOWN"
return {
"ip": ip, "sysname": sysname, "description": descr[:200],
"location": location, "contact": contact,
"interface_count": int(ifcount) if ifcount.isdigit() else 0,
"device_type": device_type,
}
async def _run_snmp_scan(config_id: int, req: SNMPScanRequest, user_id: int, db: AsyncSession):
"""백그라운드 SNMP 스캔."""
import ipaddress
try:
network = ipaddress.ip_network(req.network_range, strict=False)
tasks = [
_scan_snmp_host(str(h), req.community, req.port, req.timeout_sec)
for h in list(network.hosts())[:254]
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, dict):
device = SNMPDevice(
config_id=config_id,
ip_addr=result["ip"],
sysname=result["sysname"],
description=result["description"],
device_type=result["device_type"],
location=result["location"],
interface_count=result["interface_count"],
discovered_at=datetime.utcnow(),
)
db.add(device)
await db.commit()
except Exception as e:
logger.error(f"SNMP 스캔 실패: {e}")
@router.post("/configs")
async def create_snmp_config(
req: SNMPConfigCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
cfg = SNMPConfig(
tenant_id=user.tenant_id,
name=req.name,
community_enc=req.community, # TODO: AES-256-GCM 암호화
version=req.version,
ip_ranges=req.ip_ranges,
is_active=True,
created_at=datetime.utcnow(),
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return {"ok": True, "id": cfg.id}
@router.get("/configs")
async def list_snmp_configs(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(SNMPConfig).where(SNMPConfig.tenant_id == user.tenant_id)
)
cfgs = rows.scalars().all()
return [
{"id": c.id, "name": c.name, "version": c.version,
"ip_ranges": c.ip_ranges, "is_active": c.is_active}
for c in cfgs
]
@router.post("/scan")
async def start_snmp_scan(
req: SNMPScanRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
# 기본 SNMP 설정 생성
cfg = SNMPConfig(
tenant_id=user.tenant_id, name=f"scan_{datetime.utcnow().strftime('%Y%m%d%H%M')}",
community_enc=req.community, version=req.version,
is_active=True, created_at=datetime.utcnow(),
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
background_tasks.add_task(_run_snmp_scan, cfg.id, req, user.id, db)
return {"ok": True, "config_id": cfg.id, "network": req.network_range}
@router.get("/devices")
async def list_snmp_devices(
limit: int = 100,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(SNMPDevice, SNMPConfig.tenant_id).join(
SNMPConfig, SNMPDevice.config_id == SNMPConfig.id
).where(SNMPConfig.tenant_id == user.tenant_id)
.order_by(desc(SNMPDevice.discovered_at)).limit(limit)
)
return [
{
"id": r.SNMPDevice.id, "ip": r.SNMPDevice.ip_addr,
"name": r.SNMPDevice.sysname, "type": r.SNMPDevice.device_type,
"location": r.SNMPDevice.location, "interfaces": r.SNMPDevice.interface_count,
"discovered_at": r.SNMPDevice.discovered_at,
}
for r in rows.all()
]