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], }