first commit
This commit is contained in:
BIN
app/services/__pycache__/auth_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/auth_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/proxmox_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/proxmox_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/ssh_tunnel_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/ssh_tunnel_service.cpython-312.pyc
Normal file
Binary file not shown.
87
app/services/auth_service.py
Normal file
87
app/services/auth_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.auth import UserRegister, Token
|
||||
from app.utils.security import hash_password, verify_password
|
||||
from app.utils.jwt_handler import create_access_token, create_refresh_token
|
||||
from app.utils.exceptions import AuthenticationError, ConflictError
|
||||
|
||||
class AuthService:
|
||||
"""인증 관련 비즈니스 로직"""
|
||||
|
||||
@staticmethod
|
||||
def register_user(db: Session, user_data: UserRegister) -> User:
|
||||
"""사용자 등록"""
|
||||
# 중복 체크
|
||||
if db.query(User).filter(User.username == user_data.username).first():
|
||||
raise ConflictError("이미 존재하는 사용자명입니다")
|
||||
|
||||
if db.query(User).filter(User.email == user_data.email).first():
|
||||
raise ConflictError("이미 존재하는 이메일입니다")
|
||||
|
||||
# 새 사용자 생성
|
||||
new_user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
hashed_password=hash_password(user_data.password),
|
||||
full_name=user_data.full_name,
|
||||
role=UserRole.USER
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return new_user
|
||||
|
||||
@staticmethod
|
||||
def authenticate_user(db: Session, username: str, password: str) -> User:
|
||||
"""사용자 인증"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user:
|
||||
raise AuthenticationError("사용자를 찾을 수 없습니다")
|
||||
|
||||
if not user.is_active:
|
||||
raise AuthenticationError("비활성화된 계정입니다")
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
raise AuthenticationError("비밀번호가 일치하지 않습니다")
|
||||
|
||||
# 마지막 로그인 시간 업데이트
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_tokens(user: User) -> Token:
|
||||
"""JWT 토큰 생성"""
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
"username": user.username,
|
||||
"role": user.role.value
|
||||
}
|
||||
|
||||
access_token = create_access_token(token_data)
|
||||
refresh_token = create_refresh_token(token_data)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=1800 # 30분
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
|
||||
"""사용자 ID로 조회"""
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
||||
"""사용자명으로 조회"""
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
auth_service = AuthService()
|
||||
140
app/services/proxmox_service.py
Normal file
140
app/services/proxmox_service.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import httpx
|
||||
from typing import List, Optional, Dict
|
||||
from app.config import settings
|
||||
import json
|
||||
|
||||
class ProxmoxService:
|
||||
"""Proxmox VE API 통신 서비스"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.PROXMOX_HOST
|
||||
self.api_token = settings.PROXMOX_API_TOKEN
|
||||
self.verify_ssl = settings.PROXMOX_VERIFY_SSL
|
||||
|
||||
async def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict:
|
||||
"""Proxmox API 요청"""
|
||||
url = f"{self.base_url}/api2/json{endpoint}"
|
||||
headers = {"Authorization": self.api_token}
|
||||
|
||||
async with httpx.AsyncClient(verify=self.verify_ssl, timeout=30.0) as client:
|
||||
response = await client.request(method, url, headers=headers, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_nodes(self) -> List[Dict]:
|
||||
"""노드 목록 조회"""
|
||||
result = await self._make_request("GET", "/nodes")
|
||||
return result.get("data", [])
|
||||
|
||||
async def get_vms(self, node: str) -> List[Dict]:
|
||||
"""특정 노드의 VM 목록 조회"""
|
||||
result = await self._make_request("GET", f"/nodes/{node}/qemu")
|
||||
return result.get("data", [])
|
||||
|
||||
async def get_all_vms(self) -> List[Dict]:
|
||||
"""모든 노드의 VM 목록 조회"""
|
||||
nodes = await self.get_nodes()
|
||||
print(f"DEBUG: 노드 목록: {nodes}")
|
||||
|
||||
all_vms = []
|
||||
|
||||
for node in nodes:
|
||||
node_name = node.get("node")
|
||||
if node_name:
|
||||
vms = await self.get_vms(node_name)
|
||||
print(f"DEBUG: {node_name} 노드의 VM 목록: {vms}")
|
||||
for vm in vms:
|
||||
vm["node"] = node_name
|
||||
all_vms.append(vm)
|
||||
|
||||
print(f"DEBUG: 전체 VM 개수: {len(all_vms)}")
|
||||
print(f"DEBUG: 전체 VM 목록: {all_vms}")
|
||||
return all_vms
|
||||
|
||||
async def get_vm_status(self, node: str, vm_id: int) -> Dict:
|
||||
"""VM 상태 조회"""
|
||||
result = await self._make_request("GET", f"/nodes/{node}/qemu/{vm_id}/status/current")
|
||||
return result.get("data", {})
|
||||
|
||||
async def get_vm_ip(self, node: str, vm_id: int) -> Optional[str]:
|
||||
"""QEMU Guest Agent를 통해 VM IP 주소 조회"""
|
||||
try:
|
||||
result = await self._make_request(
|
||||
"GET",
|
||||
f"/nodes/{node}/qemu/{vm_id}/agent/network-get-interfaces"
|
||||
)
|
||||
|
||||
interfaces = result.get("data", {}).get("result", [])
|
||||
|
||||
for iface in interfaces:
|
||||
# loopback 제외
|
||||
if "loopback" in iface.get("name", "").lower():
|
||||
continue
|
||||
|
||||
ip_addresses = iface.get("ip-addresses", [])
|
||||
for ip in ip_addresses:
|
||||
if ip.get("ip-address-type") == "ipv4":
|
||||
address = ip.get("ip-address")
|
||||
# 사설 IP만 반환
|
||||
if self._is_private_ip(address):
|
||||
return address
|
||||
|
||||
return None
|
||||
except:
|
||||
return None
|
||||
|
||||
async def start_vm(self, node: str, vm_id: int) -> bool:
|
||||
"""VM 시작"""
|
||||
try:
|
||||
await self._make_request("POST", f"/nodes/{node}/qemu/{vm_id}/status/start")
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
async def stop_vm(self, node: str, vm_id: int) -> bool:
|
||||
"""VM 종료"""
|
||||
try:
|
||||
await self._make_request("POST", f"/nodes/{node}/qemu/{vm_id}/status/stop")
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
async def reboot_vm(self, node: str, vm_id: int) -> bool:
|
||||
"""VM 재시작"""
|
||||
try:
|
||||
await self._make_request("POST", f"/nodes/{node}/qemu/{vm_id}/status/reboot")
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def _is_private_ip(self, ip: str) -> bool:
|
||||
"""사설 IP 주소 확인"""
|
||||
if not ip:
|
||||
return False
|
||||
|
||||
parts = ip.split(".")
|
||||
if len(parts) != 4:
|
||||
return False
|
||||
|
||||
try:
|
||||
first = int(parts[0])
|
||||
second = int(parts[1])
|
||||
|
||||
# 10.0.0.0/8
|
||||
if first == 10:
|
||||
return True
|
||||
|
||||
# 172.16.0.0/12
|
||||
if first == 172 and 16 <= second <= 31:
|
||||
return True
|
||||
|
||||
# 192.168.0.0/16
|
||||
if first == 192 and second == 168:
|
||||
return True
|
||||
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
proxmox_service = ProxmoxService()
|
||||
220
app/services/ssh_tunnel_service.py
Normal file
220
app/services/ssh_tunnel_service.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import asyncio
|
||||
import asyncssh
|
||||
import secrets
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime
|
||||
from app.config import settings
|
||||
from app.utils.exceptions import InternalServerError
|
||||
|
||||
|
||||
class SSHTunnelManager:
|
||||
"""SSH 터널 관리"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_tunnels: Dict[str, 'TunnelSession'] = {}
|
||||
self.used_ports = set()
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 포트 관리자
|
||||
# ---------------------------------------------------------
|
||||
def _get_available_port(self) -> int:
|
||||
"""사용 가능한 포트 찾기"""
|
||||
for port in range(settings.TUNNEL_PORT_MIN, settings.TUNNEL_PORT_MAX):
|
||||
if port not in self.used_ports:
|
||||
self.used_ports.add(port)
|
||||
return port
|
||||
raise InternalServerError("사용 가능한 포트가 없습니다")
|
||||
|
||||
def _release_port(self, port: int):
|
||||
"""포트 해제"""
|
||||
self.used_ports.discard(port)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 터널 생성
|
||||
# ---------------------------------------------------------
|
||||
async def create_tunnel(
|
||||
self,
|
||||
user_id: int,
|
||||
vm_id: int,
|
||||
remote_host: str,
|
||||
remote_port: int = 3389,
|
||||
vm_name: str = "",
|
||||
rdp_username: str = ""
|
||||
) -> Dict:
|
||||
"""
|
||||
SSH 터널 생성
|
||||
"""
|
||||
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
local_port = self._get_available_port()
|
||||
|
||||
try:
|
||||
ssh_config = {
|
||||
"host": settings.SSH_HOST,
|
||||
"port": settings.SSH_PORT,
|
||||
"username": settings.SSH_USERNAME,
|
||||
"known_hosts": None
|
||||
}
|
||||
|
||||
if settings.SSH_KEY_PATH:
|
||||
ssh_config["client_keys"] = [settings.SSH_KEY_PATH]
|
||||
elif settings.SSH_PASSWORD:
|
||||
ssh_config["password"] = settings.SSH_PASSWORD
|
||||
else:
|
||||
raise InternalServerError("SSH 인증 정보가 설정되지 않았습니다")
|
||||
|
||||
# SSH 연결 생성
|
||||
conn = await asyncssh.connect(**ssh_config)
|
||||
|
||||
# 로컬 포트 포워딩 (외부 접속 허용)
|
||||
listener = await conn.forward_local_port(
|
||||
'0.0.0.0', # 모든 인터페이스에서 리스닝 (외부 접속 허용)
|
||||
local_port,
|
||||
remote_host,
|
||||
remote_port
|
||||
)
|
||||
|
||||
# 세션 저장
|
||||
tunnel_session = TunnelSession(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
vm_id=vm_id,
|
||||
local_port=local_port,
|
||||
remote_host=remote_host,
|
||||
remote_port=remote_port,
|
||||
connection=conn,
|
||||
listener=listener,
|
||||
created_at=datetime.utcnow(),
|
||||
vm_name=vm_name or "",
|
||||
rdp_username=rdp_username or ""
|
||||
)
|
||||
|
||||
self.active_tunnels[session_id] = tunnel_session
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"local_port": local_port,
|
||||
"remote_host": remote_host,
|
||||
"remote_port": remote_port,
|
||||
"ssh_host": settings.SSH_HOST,
|
||||
"vm_name": vm_name or "",
|
||||
"rdp_username": rdp_username or "",
|
||||
"created_at": tunnel_session.created_at.isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self._release_port(local_port)
|
||||
raise InternalServerError(f"SSH 터널 생성 실패: {str(e)}")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 터널 종료
|
||||
# ---------------------------------------------------------
|
||||
async def close_tunnel(self, session_id: str) -> bool:
|
||||
tunnel = self.active_tunnels.get(session_id)
|
||||
if not tunnel:
|
||||
return False
|
||||
|
||||
try:
|
||||
if tunnel.listener:
|
||||
tunnel.listener.close()
|
||||
await tunnel.listener.wait_closed()
|
||||
|
||||
if tunnel.connection:
|
||||
tunnel.connection.close()
|
||||
await tunnel.connection.wait_closed()
|
||||
|
||||
self._release_port(tunnel.local_port)
|
||||
|
||||
del self.active_tunnels[session_id]
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"터널 종료 오류: {e}")
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 터널 상태 조회
|
||||
# ---------------------------------------------------------
|
||||
async def get_tunnel_status(self, session_id: str) -> Optional[Dict]:
|
||||
tunnel = self.active_tunnels.get(session_id)
|
||||
if not tunnel:
|
||||
return None
|
||||
|
||||
is_active = tunnel.connection is not None and tunnel.listener is not None
|
||||
uptime = (datetime.utcnow() - tunnel.created_at).total_seconds()
|
||||
|
||||
# 항상 null 반환하지 않도록 빈 문자열 처리
|
||||
vm_name = tunnel.vm_name or ""
|
||||
rdp_username = tunnel.rdp_username or ""
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"is_active": is_active,
|
||||
"uptime_seconds": int(uptime),
|
||||
"created_at": tunnel.created_at.isoformat(),
|
||||
"vm_id": tunnel.vm_id,
|
||||
"vm_name": vm_name,
|
||||
"rdp_username": rdp_username,
|
||||
"tunnel_info": {
|
||||
"session_id": session_id,
|
||||
"local_port": tunnel.local_port,
|
||||
"remote_host": tunnel.remote_host,
|
||||
"remote_port": tunnel.remote_port,
|
||||
"vm_id": tunnel.vm_id,
|
||||
"vm_name": vm_name,
|
||||
"rdp_username": rdp_username,
|
||||
"is_active": is_active
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 비활성 터널 정리
|
||||
# ---------------------------------------------------------
|
||||
async def cleanup_inactive_tunnels(self):
|
||||
to_remove = []
|
||||
|
||||
for sid, tunnel in self.active_tunnels.items():
|
||||
if tunnel.connection is None or tunnel.listener is None:
|
||||
to_remove.append(sid)
|
||||
|
||||
for sid in to_remove:
|
||||
await self.close_tunnel(sid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# 터널 세션 클래스
|
||||
# ---------------------------------------------------------
|
||||
class TunnelSession:
|
||||
"""터널 세션 정보"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
user_id: int,
|
||||
vm_id: int,
|
||||
local_port: int,
|
||||
remote_host: str,
|
||||
remote_port: int,
|
||||
connection,
|
||||
listener,
|
||||
created_at: datetime,
|
||||
vm_name: str = "",
|
||||
rdp_username: str = ""
|
||||
):
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
self.vm_id = vm_id
|
||||
self.local_port = local_port
|
||||
self.remote_host = remote_host
|
||||
self.remote_port = remote_port
|
||||
self.connection = connection
|
||||
self.listener = listener
|
||||
self.created_at = created_at
|
||||
|
||||
# 절대 null 반환 방지
|
||||
self.vm_name = vm_name or ""
|
||||
self.rdp_username = rdp_username or ""
|
||||
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
ssh_tunnel_manager = SSHTunnelManager()
|
||||
78
app/services/temp_ssh_password_service.py
Normal file
78
app/services/temp_ssh_password_service.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import secrets
|
||||
import hashlib
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class TempSshPasswordManager:
|
||||
"""임시 SSH 비밀번호 관리"""
|
||||
|
||||
def __init__(self):
|
||||
# 메모리 기반 저장소 (프로덕션에서는 Redis 사용 권장)
|
||||
self._passwords: Dict[str, dict] = {}
|
||||
|
||||
def generate_password(self, username: str, validity_hours: int = 1) -> str:
|
||||
"""
|
||||
임시 SSH 비밀번호 생성
|
||||
|
||||
Args:
|
||||
username: 사용자명
|
||||
validity_hours: 유효 시간 (기본 1시간)
|
||||
|
||||
Returns:
|
||||
임시 비밀번호
|
||||
"""
|
||||
# 안전한 랜덤 비밀번호 생성 (32자)
|
||||
temp_password = secrets.token_urlsafe(32)
|
||||
|
||||
# 해시 저장 (실제 비밀번호는 저장하지 않음)
|
||||
password_hash = hashlib.sha256(temp_password.encode()).hexdigest()
|
||||
|
||||
# 만료 시간 계산
|
||||
expires_at = datetime.utcnow() + timedelta(hours=validity_hours)
|
||||
|
||||
# 저장
|
||||
self._passwords[username] = {
|
||||
"password_hash": password_hash,
|
||||
"expires_at": expires_at,
|
||||
"created_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
return temp_password
|
||||
|
||||
def verify_password(self, username: str, password: str) -> bool:
|
||||
"""
|
||||
비밀번호 검증
|
||||
|
||||
Args:
|
||||
username: 사용자명
|
||||
password: 검증할 비밀번호
|
||||
|
||||
Returns:
|
||||
유효 여부
|
||||
"""
|
||||
if username not in self._passwords:
|
||||
return False
|
||||
|
||||
stored = self._passwords[username]
|
||||
|
||||
# 만료 확인
|
||||
if datetime.utcnow() > stored["expires_at"]:
|
||||
del self._passwords[username]
|
||||
return False
|
||||
|
||||
# 비밀번호 확인
|
||||
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
return password_hash == stored["password_hash"]
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""만료된 비밀번호 정리"""
|
||||
now = datetime.utcnow()
|
||||
expired = [
|
||||
username for username, data in self._passwords.items()
|
||||
if now > data["expires_at"]
|
||||
]
|
||||
for username in expired:
|
||||
del self._passwords[username]
|
||||
|
||||
# 싱글톤 인스턴스
|
||||
temp_ssh_password_manager = TempSshPasswordManager()
|
||||
Reference in New Issue
Block a user