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