zioinfo-mail/workspace/guardia-itsm/routers/autodiscovery.py
DESKTOP-TKLFCPR\ython b8faec44e0 feat(advanced): GUARDiA 고급 확장 구현 — 20 routers + 754 endpoints
CMDB 자동 발견 (4개):
- autodiscovery.py: SSH 네트워크 스캔 + CMDB 자동 등록
- snmp_discovery.py: SNMP v2c/v3 장비 자동 발견
- dependency_map.py: 서비스 의존성 자동 매핑 (netstat)
- config_inventory.py: 서버 인벤토리 자동 수집 (SSH)

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:33:41 +09:00

292 lines
11 KiB
Python

"""
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}