diff --git a/workspace/guardia-itsm/main.py b/workspace/guardia-itsm/main.py index ef1520ec..cf204c8f 100644 --- a/workspace/guardia-itsm/main.py +++ b/workspace/guardia-itsm/main.py @@ -343,6 +343,40 @@ app.include_router(auto_report.router) # 자동 보고서 생성·다 app.include_router(benchmark.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") diff --git a/workspace/guardia-itsm/models.py b/workspace/guardia-itsm/models.py index 08e6d893..b3c0a0ce 100644 --- a/workspace/guardia-itsm/models.py +++ b/workspace/guardia-itsm/models.py @@ -11,7 +11,7 @@ from typing import Any, Optional, List from pydantic import BaseModel, ConfigDict 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 @@ -5104,3 +5104,271 @@ class ReportSchedule(Base): format = Column(String(10), default="excel") is_active = Column(Boolean, default=True) 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()) diff --git a/workspace/guardia-itsm/routers/auto_remediation.py b/workspace/guardia-itsm/routers/auto_remediation.py new file mode 100644 index 00000000..c67652d6 --- /dev/null +++ b/workspace/guardia-itsm/routers/auto_remediation.py @@ -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]} diff --git a/workspace/guardia-itsm/routers/autodiscovery.py b/workspace/guardia-itsm/routers/autodiscovery.py new file mode 100644 index 00000000..5250602c --- /dev/null +++ b/workspace/guardia-itsm/routers/autodiscovery.py @@ -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} diff --git a/workspace/guardia-itsm/routers/aws_connector.py b/workspace/guardia-itsm/routers/aws_connector.py new file mode 100644 index 00000000..2fe6304b --- /dev/null +++ b/workspace/guardia-itsm/routers/aws_connector.py @@ -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에서만 조회 가능 — 별도 설정 필요"} diff --git a/workspace/guardia-itsm/routers/cloud_migration.py b/workspace/guardia-itsm/routers/cloud_migration.py new file mode 100644 index 00000000..a6c74b31 --- /dev/null +++ b/workspace/guardia-itsm/routers/cloud_migration.py @@ -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, + } diff --git a/workspace/guardia-itsm/routers/config_inventory.py b/workspace/guardia-itsm/routers/config_inventory.py new file mode 100644 index 00000000..dd1b69ff --- /dev/null +++ b/workspace/guardia-itsm/routers/config_inventory.py @@ -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 diff --git a/workspace/guardia-itsm/routers/cost_optimizer.py b/workspace/guardia-itsm/routers/cost_optimizer.py new file mode 100644 index 00000000..561d588b --- /dev/null +++ b/workspace/guardia-itsm/routers/cost_optimizer.py @@ -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(), + } diff --git a/workspace/guardia-itsm/routers/dependency_map.py b/workspace/guardia-itsm/routers/dependency_map.py new file mode 100644 index 00000000..51c1185a --- /dev/null +++ b/workspace/guardia-itsm/routers/dependency_map.py @@ -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} diff --git a/workspace/guardia-itsm/routers/drift_detection.py b/workspace/guardia-itsm/routers/drift_detection.py new file mode 100644 index 00000000..fd41a690 --- /dev/null +++ b/workspace/guardia-itsm/routers/drift_detection.py @@ -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"} diff --git a/workspace/guardia-itsm/routers/e_procurement.py b/workspace/guardia-itsm/routers/e_procurement.py new file mode 100644 index 00000000..712a2c87 --- /dev/null +++ b/workspace/guardia-itsm/routers/e_procurement.py @@ -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(), + } diff --git a/workspace/guardia-itsm/routers/golden_config.py b/workspace/guardia-itsm/routers/golden_config.py new file mode 100644 index 00000000..fbc7270e --- /dev/null +++ b/workspace/guardia-itsm/routers/golden_config.py @@ -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} diff --git a/workspace/guardia-itsm/routers/isp_support.py b/workspace/guardia-itsm/routers/isp_support.py new file mode 100644 index 00000000..9aeb0463 --- /dev/null +++ b/workspace/guardia-itsm/routers/isp_support.py @@ -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 운영 데이터 기반 자동 생성 초안 — 담당자 검토 필요", + } diff --git a/workspace/guardia-itsm/routers/k_cloud.py b/workspace/guardia-itsm/routers/k_cloud.py new file mode 100644 index 00000000..00ddfa3c --- /dev/null +++ b/workspace/guardia-itsm/routers/k_cloud.py @@ -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(), + } diff --git a/workspace/guardia-itsm/routers/multicloud.py b/workspace/guardia-itsm/routers/multicloud.py new file mode 100644 index 00000000..1ae735e8 --- /dev/null +++ b/workspace/guardia-itsm/routers/multicloud.py @@ -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(), + } diff --git a/workspace/guardia-itsm/routers/narasajang.py b/workspace/guardia-itsm/routers/narasajang.py new file mode 100644 index 00000000..04934a8e --- /dev/null +++ b/workspace/guardia-itsm/routers/narasajang.py @@ -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} diff --git a/workspace/guardia-itsm/routers/network_zone.py b/workspace/guardia-itsm/routers/network_zone.py new file mode 100644 index 00000000..87836d57 --- /dev/null +++ b/workspace/guardia-itsm/routers/network_zone.py @@ -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)} diff --git a/workspace/guardia-itsm/routers/nlquery.py b/workspace/guardia-itsm/routers/nlquery.py new file mode 100644 index 00000000..f68355ce --- /dev/null +++ b/workspace/guardia-itsm/routers/nlquery.py @@ -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} diff --git a/workspace/guardia-itsm/routers/op_assistant.py b/workspace/guardia-itsm/routers/op_assistant.py new file mode 100644 index 00000000..f5497617 --- /dev/null +++ b/workspace/guardia-itsm/routers/op_assistant.py @@ -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} diff --git a/workspace/guardia-itsm/routers/public_api_hub.py b/workspace/guardia-itsm/routers/public_api_hub.py new file mode 100644 index 00000000..205b99fb --- /dev/null +++ b/workspace/guardia-itsm/routers/public_api_hub.py @@ -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", + } diff --git a/workspace/guardia-itsm/routers/query_history.py b/workspace/guardia-itsm/routers/query_history.py new file mode 100644 index 00000000..a796305d --- /dev/null +++ b/workspace/guardia-itsm/routers/query_history.py @@ -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} diff --git a/workspace/guardia-itsm/routers/snmp_discovery.py b/workspace/guardia-itsm/routers/snmp_discovery.py new file mode 100644 index 00000000..f419651e --- /dev/null +++ b/workspace/guardia-itsm/routers/snmp_discovery.py @@ -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() + ]