""" 네트워크 장비 관리 API. 엔드포인트: GET /api/network/devices 장비 목록 POST /api/network/devices 장비 등록 (ADMIN) GET /api/network/devices/{id} 장비 상세 PUT /api/network/devices/{id} 장비 수정 (ADMIN) DELETE /api/network/devices/{id} 장비 비활성화 (ADMIN) POST /api/network/devices/{id}/backup 설정 백업 실행 GET /api/network/devices/{id}/backups 백업 이력 GET /api/network/devices/{id}/diff 설정 변경 비교 POST /api/network/devices/{id}/command SSH 명령 실행 GET /api/network/topology 네트워크 토폴로지 """ from __future__ import annotations import logging from datetime import datetime from typing import List, 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, require_admin_role from database import get_db from models import NetworkDevice, NetworkConfigBackup, User, UserRole logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/network", tags=["network"]) # ── 권한 ───────────────────────────────────────────────────────────────────── def _require_ops(current_user: User = Depends(get_current_user)) -> User: if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "네트워크 관리 권한이 없습니다.") return current_user # ── Pydantic 스키마 ────────────────────────────────────────────────────────── class DeviceCreate(BaseModel): device_name: str device_type: str # SWITCH | ROUTER | FIREWALL | LOAD_BALANCER vendor: str # CISCO | HUAWEI | JUNIPER | PIOLINK | SECUI | RADWARE model: Optional[str] = None os_type: str = "cisco_ios" # cisco_ios | huawei_vrp | junos | linux ip_addr: str # 저장용 (API 응답 미포함) ssh_user: str # 저장용 (API 응답 미포함) ssh_password: str # 저장 전 AES-256 암호화 ssh_port: int = 22 location: Optional[str] = None inst_id: Optional[int] = None class DeviceOut(BaseModel): id: int device_name: str device_type: str vendor: str model: Optional[str] os_type: str # ip_addr, ssh_user, ssh_pw_enc 절대 미포함 location: Optional[str] inst_id: Optional[int] is_active: bool last_backup_at: Optional[datetime] model_config = {"from_attributes": True} class BackupOut(BaseModel): id: int device_id: int config_hash: str backup_type: str backed_up_at: datetime backed_up_by: Optional[str] # config_text 미포함 (대용량) model_config = {"from_attributes": True} class CommandRequest(BaseModel): command: str timeout: int = 30 # ── 엔드포인트 ─────────────────────────────────────────────────────────────── @router.get("/devices", response_model=List[DeviceOut]) async def list_devices( inst_id: Optional[int] = None, device_type: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """네트워크 장비 목록.""" q = select(NetworkDevice).where(NetworkDevice.is_active == True) if inst_id: q = q.where(NetworkDevice.inst_id == inst_id) if device_type: q = q.where(NetworkDevice.device_type == device_type) result = await db.execute(q.order_by(NetworkDevice.device_name)) return result.scalars().all() @router.post("/devices", response_model=DeviceOut, status_code=201) async def create_device( body: DeviceCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_admin_role), ): """네트워크 장비 등록 (ADMIN 전용). 비밀번호는 AES-256-GCM 암호화 저장.""" from core.ssh_exec import _encrypt_password try: pw_enc = _encrypt_password(body.ssh_password) except Exception: raise HTTPException(500, "자격증명 암호화 실패") device = NetworkDevice( device_name=body.device_name, device_type=body.device_type, vendor=body.vendor, model=body.model, os_type=body.os_type, ip_addr=body.ip_addr, ssh_user=body.ssh_user, ssh_pw_enc=pw_enc, ssh_port=body.ssh_port, location=body.location, inst_id=body.inst_id, ) db.add(device) await db.commit() await db.refresh(device) return device @router.get("/devices/{device_id}", response_model=DeviceOut) async def get_device( device_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): q = await db.execute(select(NetworkDevice).where(NetworkDevice.id == device_id)) d = q.scalar_one_or_none() if not d: raise HTTPException(404, "장비를 찾을 수 없습니다.") return d @router.put("/devices/{device_id}", response_model=DeviceOut) async def update_device( device_id: int, body: DeviceCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_admin_role), ): from core.ssh_exec import _encrypt_password q = await db.execute(select(NetworkDevice).where(NetworkDevice.id == device_id)) d = q.scalar_one_or_none() if not d: raise HTTPException(404, "장비를 찾을 수 없습니다.") d.device_name = body.device_name d.device_type = body.device_type d.vendor = body.vendor d.model = body.model d.os_type = body.os_type d.ip_addr = body.ip_addr d.ssh_user = body.ssh_user d.ssh_pw_enc = _encrypt_password(body.ssh_password) d.ssh_port = body.ssh_port d.location = body.location d.inst_id = body.inst_id await db.commit() await db.refresh(d) return d @router.delete("/devices/{device_id}", status_code=204) async def deactivate_device( device_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_admin_role), ): """장비 비활성화 (삭제 아님).""" q = await db.execute(select(NetworkDevice).where(NetworkDevice.id == device_id)) d = q.scalar_one_or_none() if not d: raise HTTPException(404, "장비를 찾을 수 없습니다.") d.is_active = False await db.commit() @router.post("/devices/{device_id}/backup") async def backup_device_config( device_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """장비 설정 백업 실행. 이전 설정과 diff 비교 결과 포함.""" from core.network_scanner import NetworkScanner result = await NetworkScanner().backup_config( db, device_id, backup_type="MANUAL", backed_up_by=current_user.username ) if not result["success"]: raise HTTPException(400, result.get("error", "백업 실패")) return result @router.get("/devices/{device_id}/backups", response_model=List[BackupOut]) async def list_device_backups( device_id: int, limit: int = 20, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """백업 이력 목록 (설정 내용 제외).""" q = await db.execute( select(NetworkConfigBackup) .where(NetworkConfigBackup.device_id == device_id) .order_by(desc(NetworkConfigBackup.backed_up_at)) .limit(limit) ) return q.scalars().all() @router.get("/devices/{device_id}/diff") async def get_config_diff( device_id: int, old_id: Optional[int] = None, new_id: Optional[int] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """설정 변경 비교. 파라미터 없으면 최근 2개 비교.""" from core.network_scanner import NetworkScanner result = await NetworkScanner().get_config_diff(db, device_id, old_id, new_id) if not result["success"]: raise HTTPException(400, result.get("error", "비교 실패")) return result @router.post("/devices/{device_id}/command") async def execute_device_command( device_id: int, body: CommandRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """SSH 명령 실행 (안전 명령만 허용).""" from core.network_scanner import NetworkScanner from core.ssh_exec import _decrypt_password q = await db.execute(select(NetworkDevice).where(NetworkDevice.id == device_id, NetworkDevice.is_active == True)) device = q.scalar_one_or_none() if not device: raise HTTPException(404, "장비를 찾을 수 없습니다.") scanner = NetworkScanner() if not scanner.is_command_safe(body.command): raise HTTPException(400, "허용되지 않는 명령어입니다.") try: pw = _decrypt_password(device.ssh_pw_enc) except Exception: raise HTTPException(500, "자격증명 복호화 실패") result = await scanner.execute_command( device.ip_addr, device.ssh_user, pw, device.ssh_port or 22, body.command, body.timeout ) return { "device_name": device.device_name, "command": body.command, "success": result["success"], "stdout": result["stdout"][:5000], # 최대 5000자 "stderr": result["stderr"][:500], "exit_code": result["exit_code"], } @router.get("/topology") async def get_topology( inst_id: Optional[int] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """네트워크 토폴로지 (장비 목록 + 타입별 분류).""" q = select(NetworkDevice).where(NetworkDevice.is_active == True) if inst_id: q = q.where(NetworkDevice.inst_id == inst_id) result = await db.execute(q) devices = result.scalars().all() topology: dict = {"nodes": [], "by_type": {}} for d in devices: node = { "id": d.id, "name": d.device_name, "type": d.device_type, "vendor": d.vendor, "location": d.location, "inst_id": d.inst_id, "last_backup_at": d.last_backup_at.isoformat() if d.last_backup_at else None, } topology["nodes"].append(node) topology["by_type"].setdefault(d.device_type, []).append(node) return { "total": len(devices), "topology": topology, }