- ITSM: app_deploy.py (APK 업로드·QR 생성·랜딩 페이지) - ITSM: batch_ssh.py (다중 서버 동시 SSH 실행) - ITSM: asset_qr.py (자산 QR 태그·체크인·라벨 인쇄) - ITSM: smart_notify.py (조건 기반 알림 규칙 엔진) - ITSM: models.py (AppVersion/BatchSSHJob/AssetQRToken/SmartNotifyRule 등 7개 모델) - ITSM: main.py (4개 신규 라우터 등록) - ITSM: static/app.js (앱배포·배치SSH·자산QR·알림규칙 4개 뷰) - ITSM: static/index.html (신규 사이드바 메뉴 4개) - Manager: AppDistribution.tsx (APK 업로드 UI·QR 표시·버전 관리) - Manager: NotificationRules.tsx (알림 규칙 편집기) - Manager: App.tsx + Sidebar.tsx (신규 라우트 등록) - Mail: contacts.py (주소록 CRUD·자동완성) - Mail: signature.py (HTML 서명 관리) - Mail: Contacts.tsx + SignatureEditor.tsx (프론트엔드 컴포넌트) - Messenger: scan.tsx (자산 QR 스캔 탭) - Messenger: _layout.tsx (QR 탭 추가) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
216 lines
6.9 KiB
Python
216 lines
6.9 KiB
Python
"""
|
|
다중 서버 배치 SSH 실행
|
|
|
|
여러 서버에 동일 명령을 동시에 실행하고 결과를 수집.
|
|
PAM 승인 게이트 적용 — 위험 명령어는 관리자 승인 필요.
|
|
|
|
엔드포인트:
|
|
POST /api/batch-ssh/run — 배치 명령 실행 (비동기)
|
|
GET /api/batch-ssh/jobs — 작업 목록
|
|
GET /api/batch-ssh/jobs/{id} — 작업 결과 상세
|
|
DELETE /api/batch-ssh/jobs/{id} — 작업 삭제
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
from typing import List, 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 core.ssh_exec import _decrypt_password as decrypt_password
|
|
from database import get_db
|
|
from models import User, Server, BatchSSHJob, AuditLog
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/batch-ssh", tags=["배치 SSH"])
|
|
|
|
# 위험 명령어 패턴 (PAM 승인 필요)
|
|
DANGEROUS_PATTERNS = [
|
|
r'\brm\s+-rf\b', r'\bmkfs\b', r'\bdd\b.*if=',
|
|
r'\bshutdown\b', r'\breboot\b', r'\bhalt\b',
|
|
r'\bchmod\s+777\b', r'\bchown\s+.*root\b',
|
|
r'>\s*/etc/(passwd|shadow|sudoers)',
|
|
]
|
|
|
|
|
|
class BatchSSHRequest(BaseModel):
|
|
server_ids: List[int] = Field(..., min_length=1, max_length=50)
|
|
command: str = Field(..., min_length=1, max_length=500)
|
|
timeout_sec: int = Field(30, ge=5, le=300)
|
|
require_approval: bool = False
|
|
|
|
|
|
def _is_dangerous(command: str) -> bool:
|
|
return any(re.search(p, command, re.IGNORECASE) for p in DANGEROUS_PATTERNS)
|
|
|
|
|
|
async def _run_on_server(server: Server, command: str, timeout: int) -> dict:
|
|
try:
|
|
pw = decrypt_password(server.os_pw_enc)
|
|
ssh = paramiko.SSHClient()
|
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10)
|
|
_, stdout, stderr = ssh.exec_command(command, timeout=timeout)
|
|
exit_code = stdout.channel.recv_exit_status()
|
|
out = stdout.read().decode('utf-8', 'replace').strip()
|
|
err = stderr.read().decode('utf-8', 'replace').strip()
|
|
ssh.close()
|
|
return {
|
|
"server_id": server.id,
|
|
"hostname": server.hostname or server.ip_addr,
|
|
"ip": server.ip_addr,
|
|
"exit_code": exit_code,
|
|
"stdout": out[:2000],
|
|
"stderr": err[:500],
|
|
"status": "SUCCESS" if exit_code == 0 else "FAILED",
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"server_id": server.id,
|
|
"hostname": getattr(server, 'hostname', '') or server.ip_addr,
|
|
"ip": server.ip_addr,
|
|
"exit_code": -1,
|
|
"stdout": "",
|
|
"stderr": str(e)[:200],
|
|
"status": "ERROR",
|
|
}
|
|
|
|
|
|
async def _execute_batch(job_id: int, servers: list, command: str,
|
|
timeout: int, db: AsyncSession):
|
|
job_row = await db.execute(select(BatchSSHJob).where(BatchSSHJob.id == job_id))
|
|
job = job_row.scalar_one_or_none()
|
|
if not job:
|
|
return
|
|
try:
|
|
job.status = "RUNNING"
|
|
await db.commit()
|
|
tasks = [_run_on_server(s, command, timeout) for s in servers]
|
|
results = await asyncio.gather(*tasks)
|
|
success = sum(1 for r in results if r["status"] == "SUCCESS")
|
|
job.results_json = json.dumps(results, ensure_ascii=False)
|
|
job.success_count = success
|
|
job.total_count = len(servers)
|
|
job.status = "DONE"
|
|
except Exception as e:
|
|
job.status = "FAILED"
|
|
job.results_json = json.dumps({"error": str(e)})
|
|
finally:
|
|
job.finished_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/run")
|
|
async def run_batch(
|
|
req: BatchSSHRequest,
|
|
background_tasks: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
# 위험 명령어 체크 — 관리자만 가능
|
|
if _is_dangerous(req.command):
|
|
if user.role.value not in ("ADMIN", "admin"):
|
|
raise HTTPException(403, "위험 명령어는 관리자만 실행 가능합니다")
|
|
|
|
# 서버 목록 조회
|
|
rows = await db.execute(
|
|
select(Server).where(Server.id.in_(req.server_ids))
|
|
)
|
|
servers = rows.scalars().all()
|
|
if not servers:
|
|
raise HTTPException(404, "서버를 찾을 수 없습니다")
|
|
|
|
job = BatchSSHJob(
|
|
command=req.command,
|
|
server_ids=req.server_ids,
|
|
total_count=len(servers),
|
|
timeout_sec=req.timeout_sec,
|
|
status="QUEUED",
|
|
created_by=user.id,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
db.add(job)
|
|
|
|
log = AuditLog(
|
|
user_id=user.id,
|
|
action="BATCH_SSH",
|
|
detail=f"배치 SSH: {len(servers)}개 서버, 명령: {req.command[:100]}",
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
db.add(log)
|
|
await db.commit()
|
|
await db.refresh(job)
|
|
|
|
background_tasks.add_task(_execute_batch, job.id, servers, req.command, req.timeout_sec, db)
|
|
return {"ok": True, "job_id": job.id, "server_count": len(servers)}
|
|
|
|
|
|
@router.get("/jobs")
|
|
async def list_jobs(
|
|
limit: int = 30,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
rows = await db.execute(
|
|
select(BatchSSHJob).where(BatchSSHJob.created_by == user.id)
|
|
.order_by(desc(BatchSSHJob.created_at)).limit(limit)
|
|
)
|
|
jobs = rows.scalars().all()
|
|
return [
|
|
{
|
|
"id": j.id, "command": j.command[:80],
|
|
"status": j.status,
|
|
"success": j.success_count, "total": j.total_count,
|
|
"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),
|
|
):
|
|
row = await db.execute(
|
|
select(BatchSSHJob).where(BatchSSHJob.id == job_id)
|
|
)
|
|
job = row.scalar_one_or_none()
|
|
if not job:
|
|
raise HTTPException(404)
|
|
results = json.loads(job.results_json or "[]") if job.results_json else []
|
|
return {
|
|
"id": job.id, "command": job.command,
|
|
"status": job.status,
|
|
"success": job.success_count, "total": job.total_count,
|
|
"created_at": job.created_at, "finished_at": job.finished_at,
|
|
"results": results,
|
|
}
|
|
|
|
|
|
@router.delete("/jobs/{job_id}")
|
|
async def delete_job(
|
|
job_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
row = await db.execute(
|
|
select(BatchSSHJob).where(BatchSSHJob.id == job_id, BatchSSHJob.created_by == user.id)
|
|
)
|
|
job = row.scalar_one_or_none()
|
|
if not job:
|
|
raise HTTPException(404)
|
|
await db.delete(job)
|
|
await db.commit()
|
|
return {"ok": True}
|