zioinfo-mail/itsm/core/network_scanner.py
DESKTOP-TKLFCPR\ython fc756a493e feat(itsm): DR 자동화 · 네트워크 장비 관리 · CSAP 자동 점검 3종 추가
## 구현 내용

### DR 자동화 (routers/dr.py, core/dr_engine.py)
- DR 시나리오 등록/관리 (SERVER_FAILURE | SITE_FAILURE | DATA_CORRUPTION)
- 복구 테스트 자동화 (SSH 기반 단계별 실행 + 헬스체크)
- 백업 무결성 검증 (SSH → SHA-256 해시 검증)
- RTO/RPO 목표 대비 실적 대시보드
- Failover 실행 API (ADMIN 전용)

### 네트워크 장비 관리 (routers/network_devices.py, core/network_scanner.py)
- 스위치/라우터/방화벽/L4 장비 인벤토리 (CRUD)
- 벤더별 SSH 설정 백업 (Cisco IOS / Huawei VRP / Junos / Linux)
- 이전 백업과 unified diff 변경 감지
- 위험 명령어 차단 (write erase, factory-reset 등)
- 토폴로지 조회 API

### CSAP 공공기관 보안 자동 점검 (routers/compliance.py 확장, core/csap_checker.py)
- CSAP/ISMS-P 기반 25개 항목 자동 점검
- 기술적/운영 보안 자동 검증 (SSH, DB 직접 확인)
- 수동 항목 증적 업로드
- Excel/HTML 보고서 자동 생성
- 기관별 준수율 대시보드 (A~D 등급)

### DB 모델 추가 (models.py)
- DRScenario, DRTest
- NetworkDevice, NetworkConfigBackup
- CSAPCheckResult

### 하네스 확장
- 에이전트: dr-coordinator, network-guardian, csap-auditor
- 스킬: dr-automation, network-devices, csap-compliance
- guardia-orchestrator description에 DR/네트워크/CSAP 트리거 추가

### 매뉴얼
- 39_DR_네트워크장비_CSAP_운영가이드.md 신규 작성
- 16_API_명세서.md v2.1.0 업데이트 (617개 라우트, 섹션 21~23 추가)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 09:24:51 +09:00

252 lines
10 KiB
Python

"""
네트워크 장비 SSH 접속 및 설정 관리 엔진.
벤더별(Cisco/Huawei/Juniper/Linux) 표준 명령어 추상화.
설정 백업 → SHA-256 해시 → diff 변경 감지 → 알림.
"""
from __future__ import annotations
import asyncio
import difflib
import hashlib
import logging
from datetime import datetime
from typing import Optional
import paramiko
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# ── 벤더별 표준 명령어 ─────────────────────────────────────────────────────
DEVICE_COMMANDS: dict[str, dict[str, str]] = {
"cisco_ios": {
"get_config": "show running-config",
"get_version": "show version",
"get_interfaces": "show interfaces status",
"get_vlan": "show vlan brief",
"get_arp": "show arp",
"get_route": "show ip route",
},
"huawei_vrp": {
"get_config": "display current-configuration",
"get_version": "display version",
"get_interfaces": "display interface brief",
"get_vlan": "display vlan",
"get_arp": "display arp all",
"get_route": "display ip routing-table",
},
"junos": {
"get_config": "show configuration | display set",
"get_version": "show version",
"get_interfaces": "show interfaces terse",
"get_route": "show route",
},
"linux": {
"get_config": "iptables-save 2>/dev/null || cat /etc/fw/rules.conf 2>/dev/null",
"get_version": "cat /etc/os-release",
"get_interfaces": "ip addr show",
"get_route": "ip route show",
},
}
# 위험 명령어 패턴 — 설정 변경/초기화/재부팅 방지
_BLOCKED_PATTERNS = [
"write erase", "factory-reset", "factory reset",
"reload", "reboot", "shutdown",
"rm -rf", "mkfs", "fdisk", "format flash",
"no service", "delete flash:", "erase startup",
]
class NetworkScanner:
"""네트워크 장비 SSH 접속 및 설정 관리."""
# ── 보안 검증 ───────────────────────────────────────────────────────────
def is_command_safe(self, command: str) -> bool:
"""위험 명령어 차단."""
cmd_lower = command.lower().strip()
return not any(p in cmd_lower for p in _BLOCKED_PATTERNS)
# ── SSH 명령 실행 ───────────────────────────────────────────────────────
async def execute_command(self, ip: str, user: str, password: str,
port: int, command: str,
timeout: int = 30) -> dict:
"""SSH 명령 실행. IP/계정 정보는 반환값에 포함하지 않는다."""
if not self.is_command_safe(command):
return {"success": False, "stdout": "", "stderr": "차단된 명령어입니다.",
"exit_code": -1}
try:
result = await asyncio.get_event_loop().run_in_executor(
None, self._sync_ssh_exec, ip, user, password, port, command, timeout
)
return result
except Exception as e:
logger.error("SSH exec error: %s", e)
return {"success": False, "stdout": "", "stderr": str(e)[:200], "exit_code": -1}
def _sync_ssh_exec(self, ip: str, user: str, password: str,
port: int, command: str, timeout: int) -> dict:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(ip, port=port, username=user, password=password,
timeout=15, allow_agent=False, look_for_keys=False)
_, stdout, stderr = client.exec_command(command, timeout=timeout)
exit_code = stdout.channel.recv_exit_status()
return {
"success": exit_code == 0,
"stdout": stdout.read().decode(errors="replace"),
"stderr": stderr.read().decode(errors="replace"),
"exit_code": exit_code,
}
finally:
client.close()
# ── 설정 백업 ───────────────────────────────────────────────────────────
async def backup_config(self, db: AsyncSession, device_id: int,
backup_type: str, backed_up_by: str) -> dict:
"""
장비 설정 백업 실행.
이전 백업과 diff를 비교하여 변경 감지.
"""
from models import NetworkDevice, NetworkConfigBackup
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:
return {"success": False, "error": "장비를 찾을 수 없습니다."}
try:
password = _decrypt_password(device.ssh_pw_enc)
except Exception as e:
return {"success": False, "error": "자격증명 복호화 실패"}
get_config_cmd = DEVICE_COMMANDS.get(device.os_type or "linux", {}).get("get_config", "")
if not get_config_cmd:
return {"success": False, "error": f"지원하지 않는 OS 타입: {device.os_type}"}
exec_result = await self.execute_command(
device.ip_addr, device.ssh_user, password,
device.ssh_port or 22, get_config_cmd, timeout=60
)
if not exec_result["success"]:
return {"success": False, "error": exec_result["stderr"][:200]}
config_text = exec_result["stdout"]
config_hash = hashlib.sha256(config_text.encode()).hexdigest()
# 이전 백업과 diff
prev_q = await db.execute(
select(NetworkConfigBackup)
.where(NetworkConfigBackup.device_id == device_id)
.order_by(desc(NetworkConfigBackup.backed_up_at))
.limit(1)
)
prev_backup = prev_q.scalar_one_or_none()
changed_lines = 0
diff_summary = []
if prev_backup and prev_backup.config_hash != config_hash:
diff = self.diff_configs(prev_backup.config_text or "", config_text)
changed_lines = sum(1 for line in diff if line.startswith(("+", "-"))
and not line.startswith(("+++", "---")))
diff_summary = diff[:50] # 최대 50줄만 저장
backup = NetworkConfigBackup(
device_id=device_id,
config_text=config_text,
config_hash=config_hash,
backup_type=backup_type,
backed_up_by=backed_up_by,
backed_up_at=datetime.now(),
)
db.add(backup)
# 장비 최종 백업 시각 갱신
device.last_backup_at = datetime.now()
await db.commit()
await db.refresh(backup)
return {
"success": True,
"backup_id": backup.id,
"device_name": device.device_name,
"config_hash": config_hash,
"changed_lines": changed_lines,
"diff_summary": diff_summary if changed_lines > 0 else [],
"backed_up_at": backup.backed_up_at.isoformat(),
}
# ── 설정 비교 ───────────────────────────────────────────────────────────
def diff_configs(self, old: str, new: str) -> list[str]:
"""unified diff 형식으로 설정 변경 사항 반환."""
return list(difflib.unified_diff(
old.splitlines(), new.splitlines(),
fromfile="이전 설정", tofile="현재 설정",
lineterm="", n=3,
))
async def get_config_diff(self, db: AsyncSession, device_id: int,
backup_id_old: Optional[int] = None,
backup_id_new: Optional[int] = None) -> dict:
"""두 백업 간 설정 차이 반환. ID 미지정 시 최근 2개 비교."""
from models import NetworkConfigBackup
if backup_id_old and backup_id_new:
q_old = await db.execute(
select(NetworkConfigBackup).where(
NetworkConfigBackup.id == backup_id_old,
NetworkConfigBackup.device_id == device_id,
)
)
q_new = await db.execute(
select(NetworkConfigBackup).where(
NetworkConfigBackup.id == backup_id_new,
NetworkConfigBackup.device_id == device_id,
)
)
old_b = q_old.scalar_one_or_none()
new_b = q_new.scalar_one_or_none()
else:
q = await db.execute(
select(NetworkConfigBackup)
.where(NetworkConfigBackup.device_id == device_id)
.order_by(desc(NetworkConfigBackup.backed_up_at))
.limit(2)
)
backups = q.scalars().all()
if len(backups) < 2:
return {"success": False, "error": "비교할 백업이 2개 미만입니다."}
new_b, old_b = backups[0], backups[1]
if not old_b or not new_b:
return {"success": False, "error": "백업을 찾을 수 없습니다."}
diff = self.diff_configs(old_b.config_text or "", new_b.config_text or "")
added = [l for l in diff if l.startswith("+") and not l.startswith("+++")]
removed = [l for l in diff if l.startswith("-") and not l.startswith("---")]
return {
"success": True,
"device_id": device_id,
"old_backup_id": old_b.id,
"new_backup_id": new_b.id,
"old_backed_up_at": old_b.backed_up_at.isoformat(),
"new_backed_up_at": new_b.backed_up_at.isoformat(),
"changed": len(added) + len(removed) > 0,
"added_lines": len(added),
"removed_lines": len(removed),
"diff": diff[:200], # 최대 200줄
}