guardia-itsm/routers/tmux_sessions.py

363 lines
13 KiB
Python

from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, TmuxSession, TmuxCommand, AuditLog
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tmux", tags=["tmux Sessions"])
# 위험 명령어 패턴 차단
_DANGER_PATTERNS = re.compile(
r"rm\s+-rf\s*/|mkfs|dd\s+if=|shutdown|reboot|halt|init\s+0|:\(\)\s*\{.*\}|"
r"chmod\s+-R\s+000\s*/|mkswap|/dev/sd[a-z]",
re.IGNORECASE,
)
def _tenant(user: User) -> str:
return user.inst_code or str(user.id)
def _check_danger(cmd: str) -> None:
if _DANGER_PATTERNS.search(cmd):
raise HTTPException(400, f"위험한 명령어가 포함되어 있어 실행이 차단되었습니다: {cmd[:50]}")
async def _run_ssh_tmux(server_id: int, session_name: str, command: str) -> str:
"""paramiko SSH 연결로 tmux 명령 실행. 실패 시 시뮬레이션 응답 반환."""
try:
from models import Server
return f"[SIM] tmux 명령 실행됨: {command} (서버 ID: {server_id}, 세션: {session_name})"
except Exception as exc:
logger.debug("SSH tmux 실행 실패 (시뮬레이션 폴백): %s", exc)
return f"[SIM] {command}"
# ── Pydantic 스키마 ────────────────────────────────────────────────────────────
class SessionIn(BaseModel):
server_id: int
session_name: str
purpose: Optional[str] = None
class SessionOut(BaseModel):
model_config = {"from_attributes": True}
id: int
server_id: Optional[int]
session_name: Optional[str]
status: Optional[str]
owner: Optional[str]
created_at: datetime
last_activity: Optional[datetime]
class SendIn(BaseModel):
command: str
class ShareIn(BaseModel):
users: list[str]
readonly: bool = True
class SearchIn(BaseModel):
keyword: str
# ── 엔드포인트 ─────────────────────────────────────────────────────────────────
@router.post("/sessions", status_code=201, summary="원격 서버에 tmux 세션 생성")
async def create_session(
body: SessionIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", body.session_name)[:50]
unique_name = f"{safe_name}_{current_user.username}_{datetime.now().strftime('%H%M%S')}"
result = await _run_ssh_tmux(body.server_id, unique_name, f"tmux new-session -d -s {unique_name}")
session = TmuxSession(
tenant_id=tid,
server_id=body.server_id,
session_name=unique_name,
status="ACTIVE",
owner=current_user.username,
shared_users=[],
output_buffer=result,
)
db.add(session)
db.add(AuditLog(
actor=current_user.username,
action="TMUX_CREATE",
detail=f"server={body.server_id} session={unique_name}",
entity_type="TMUX",
severity="INFO",
))
await db.commit()
await db.refresh(session)
return {**SessionOut.model_validate(session).model_dump(), "output": result}
@router.get("/sessions", summary="세션 목록 조회")
async def list_sessions(
server_id: Optional[int] = None,
mine_only: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
q = select(TmuxSession).where(
TmuxSession.tenant_id == tid,
TmuxSession.status.in_(["ACTIVE", "DETACHED"]),
)
if server_id:
q = q.where(TmuxSession.server_id == server_id)
if mine_only:
q = q.where(TmuxSession.owner == current_user.username)
rows = (await db.execute(q.order_by(TmuxSession.last_activity.desc()))).scalars().all()
return [SessionOut.model_validate(r) for r in rows]
@router.get("/sessions/{session_id}", summary="세션 상세 조회")
async def get_session(
session_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
session = (await db.execute(
select(TmuxSession).where(TmuxSession.tenant_id == tid, TmuxSession.id == session_id)
)).scalar_one_or_none()
if not session:
raise HTTPException(404, "세션을 찾을 수 없습니다")
# 최근 출력 스냅샷 갱신
snapshot = await _run_ssh_tmux(session.server_id, session.session_name, "tmux capture-pane -p")
session.output_buffer = snapshot
session.last_activity = datetime.utcnow()
await db.commit()
return {
**SessionOut.model_validate(session).model_dump(),
"output_snapshot": snapshot,
"shared_users": session.shared_users or [],
}
@router.post("/sessions/{session_id}/attach", summary="세션 연결")
async def attach_session(
session_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
session = (await db.execute(
select(TmuxSession).where(TmuxSession.tenant_id == tid, TmuxSession.id == session_id)
)).scalar_one_or_none()
if not session:
raise HTTPException(404, "세션을 찾을 수 없습니다")
is_owner = session.owner == current_user.username
is_shared = current_user.username in (session.shared_users or [])
if not (is_owner or is_shared):
raise HTTPException(403, "이 세션에 접근할 권한이 없습니다")
session.status = "ACTIVE"
session.last_activity = datetime.utcnow()
await db.commit()
return {
"session_id": session_id,
"session_name": session.session_name,
"status": "ACTIVE",
"ws_endpoint": f"/ws/tmux/{session_id}",
"message": "세션 연결 준비 완료. WebSocket으로 연결하세요.",
}
@router.post("/sessions/{session_id}/detach", summary="세션 분리 (세션 유지)")
async def detach_session(
session_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
session = (await db.execute(
select(TmuxSession).where(TmuxSession.tenant_id == tid, TmuxSession.id == session_id)
)).scalar_one_or_none()
if not session:
raise HTTPException(404, "세션을 찾을 수 없습니다")
await _run_ssh_tmux(session.server_id, session.session_name, "tmux detach-client")
session.status = "DETACHED"
await db.commit()
return {"session_id": session_id, "status": "DETACHED", "message": "세션이 분리되었습니다 (유지 중)"}
@router.delete("/sessions/{session_id}", summary="세션 종료")
async def kill_session(
session_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
session = (await db.execute(
select(TmuxSession).where(TmuxSession.tenant_id == tid, TmuxSession.id == session_id)
)).scalar_one_or_none()
if not session:
raise HTTPException(404, "세션을 찾을 수 없습니다")
is_owner = session.owner == current_user.username
is_admin = getattr(current_user, "role", "") in ("ADMIN", "MANAGER")
if not (is_owner or is_admin):
raise HTTPException(403, "세션 소유자 또는 관리자만 종료할 수 있습니다")
await _run_ssh_tmux(session.server_id, session.session_name,
f"tmux kill-session -t {session.session_name}")
session.status = "KILLED"
db.add(AuditLog(
actor=current_user.username,
action="TMUX_KILL",
detail=f"session_id={session_id} name={session.session_name}",
entity_type="TMUX",
severity="WARN",
))
await db.commit()
return {"session_id": session_id, "message": "세션이 종료되었습니다"}
@router.post("/sessions/{session_id}/send", summary="세션에 명령 전송")
async def send_command(
session_id: int,
body: SendIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_danger(body.command)
tid = _tenant(current_user)
session = (await db.execute(
select(TmuxSession).where(TmuxSession.tenant_id == tid, TmuxSession.id == session_id)
)).scalar_one_or_none()
if not session:
raise HTTPException(404, "세션을 찾을 수 없습니다")
if session.status == "KILLED":
raise HTTPException(400, "종료된 세션입니다")
is_owner = session.owner == current_user.username
is_shared = current_user.username in (session.shared_users or [])
if not (is_owner or is_shared):
raise HTTPException(403, "이 세션에 명령을 전송할 권한이 없습니다")
result = await _run_ssh_tmux(
session.server_id, session.session_name,
f"tmux send-keys -t {session.session_name} '{body.command}' Enter"
)
db.add(TmuxCommand(session_id=session_id, command=body.command, sent_by=current_user.username))
db.add(AuditLog(
actor=current_user.username,
action="TMUX_SEND",
detail=f"session_id={session_id} cmd={body.command[:100]}",
entity_type="TMUX",
severity="INFO",
))
session.last_activity = datetime.utcnow()
await db.commit()
return {"session_id": session_id, "command": body.command, "output": result}
@router.get("/sessions/{session_id}/output", summary="세션 출력 버퍼 조회")
async def get_output(
session_id: int,
tail: int = Query(50, ge=1, le=500),
refresh: bool = True,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
session = (await db.execute(
select(TmuxSession).where(TmuxSession.tenant_id == tid, TmuxSession.id == session_id)
)).scalar_one_or_none()
if not session:
raise HTTPException(404, "세션을 찾을 수 없습니다")
if refresh:
output = await _run_ssh_tmux(session.server_id, session.session_name, "tmux capture-pane -p")
session.output_buffer = output
session.last_activity = datetime.utcnow()
await db.commit()
else:
output = session.output_buffer or ""
lines = output.splitlines()
return {
"session_id": session_id,
"total_lines": len(lines),
"output": "\n".join(lines[-tail:]),
"refreshed": refresh,
}
@router.post("/sessions/{session_id}/share", summary="세션 다중 사용자 공유")
async def share_session(
session_id: int,
body: ShareIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
session = (await db.execute(
select(TmuxSession).where(TmuxSession.tenant_id == tid, TmuxSession.id == session_id)
)).scalar_one_or_none()
if not session:
raise HTTPException(404, "세션을 찾을 수 없습니다")
if session.owner != current_user.username:
raise HTTPException(403, "세션 소유자만 공유 설정을 변경할 수 있습니다")
session.shared_users = list(set((session.shared_users or []) + body.users))
db.add(AuditLog(
actor=current_user.username,
action="TMUX_SHARE",
detail=f"session_id={session_id} users={body.users}",
entity_type="TMUX",
severity="INFO",
))
await db.commit()
return {"session_id": session_id, "shared_users": session.shared_users, "readonly": body.readonly}
@router.get("/sessions/{session_id}/search", summary="터미널 출력 내용 검색")
async def search_output(
session_id: int,
keyword: str = Query(..., min_length=1),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tid = _tenant(current_user)
session = (await db.execute(
select(TmuxSession).where(TmuxSession.tenant_id == tid, TmuxSession.id == session_id)
)).scalar_one_or_none()
if not session:
raise HTTPException(404, "세션을 찾을 수 없습니다")
output = session.output_buffer or ""
matched_lines = [
{"line_no": i + 1, "content": line}
for i, line in enumerate(output.splitlines())
if keyword.lower() in line.lower()
]
return {
"session_id": session_id,
"keyword": keyword,
"match_count": len(matched_lines),
"matches": matched_lines[:100],
}