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>
292 lines
11 KiB
Python
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}
|