363 lines
13 KiB
Python
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],
|
|
}
|