first commit

This commit is contained in:
unknown
2025-12-08 21:35:55 +09:00
commit f343f405f7
5357 changed files with 923703 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

275
app/api/admin.py Normal file
View File

@@ -0,0 +1,275 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.schemas.admin import (
UserCreate, UserUpdate, UserInfo, UserListResponse,
VMAccessCreate, VMAccessUpdate, VMAccessInfo, VMAccessListResponse,
AdminResponse
)
from app.schemas.auth import CurrentUser
from app.api.auth import get_current_user
from app.models.user import User, UserRole
from app.models.vm import VMAccess
from app.utils.security import hash_password
from app.utils.exceptions import PermissionDeniedError, NotFoundError, BadRequestError
router = APIRouter()
def require_admin(current_user: CurrentUser = Depends(get_current_user)):
"""관리자 권한 확인"""
if current_user.role != "admin":
raise PermissionDeniedError("관리자 권한이 필요합니다")
return current_user
# ==================== 사용자 관리 ====================
@router.get("/users", response_model=UserListResponse)
async def get_users(
current_user: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db)
):
"""사용자 목록 조회"""
users = db.query(User).all()
user_list = [
UserInfo(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
role=user.role.value,
is_active=user.is_active,
created_at=user.created_at,
last_login=user.last_login
)
for user in users
]
return UserListResponse(total=len(user_list), users=user_list)
@router.post("/users", response_model=AdminResponse)
async def create_user(
user_data: UserCreate,
current_user: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db)
):
"""사용자 생성"""
# 중복 확인
if db.query(User).filter(User.username == user_data.username).first():
raise BadRequestError("이미 존재하는 사용자명입니다")
if db.query(User).filter(User.email == user_data.email).first():
raise BadRequestError("이미 존재하는 이메일입니다")
# 역할 유효성 검사
if user_data.role not in ["admin", "user"]:
raise BadRequestError("역할은 'admin' 또는 'user'만 가능합니다")
# 사용자 생성
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.ADMIN if user_data.role == "admin" else UserRole.USER,
is_active=True
)
db.add(new_user)
db.commit()
return AdminResponse(
success=True,
message=f"사용자 '{user_data.username}'이(가) 생성되었습니다"
)
@router.put("/users/{user_id}", response_model=AdminResponse)
async def update_user(
user_id: int,
user_data: UserUpdate,
current_user: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db)
):
"""사용자 정보 수정"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise NotFoundError("사용자를 찾을 수 없습니다")
# 이메일 중복 확인
if user_data.email and user_data.email != user.email:
if db.query(User).filter(User.email == user_data.email).first():
raise BadRequestError("이미 존재하는 이메일입니다")
user.email = user_data.email
# 비밀번호 변경
if user_data.password:
user.hashed_password = hash_password(user_data.password)
# 기타 정보 업데이트
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.role:
if user_data.role not in ["admin", "user"]:
raise BadRequestError("역할은 'admin' 또는 'user'만 가능합니다")
user.role = UserRole.ADMIN if user_data.role == "admin" else UserRole.USER
if user_data.is_active is not None:
user.is_active = user_data.is_active
db.commit()
return AdminResponse(
success=True,
message=f"사용자 '{user.username}'의 정보가 수정되었습니다"
)
@router.delete("/users/{user_id}", response_model=AdminResponse)
async def delete_user(
user_id: int,
current_user: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db)
):
"""사용자 삭제"""
# 자기 자신은 삭제 불가
if user_id == current_user.id:
raise BadRequestError("자기 자신은 삭제할 수 없습니다")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise NotFoundError("사용자를 찾을 수 없습니다")
username = user.username
db.delete(user)
db.commit()
return AdminResponse(
success=True,
message=f"사용자 '{username}'이(가) 삭제되었습니다"
)
# ==================== VM 접근 권한 관리 ====================
@router.get("/vm-access", response_model=VMAccessListResponse)
async def get_vm_access_list(
current_user: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db)
):
"""VM 접근 권한 목록 조회"""
accesses = db.query(VMAccess).join(User).all()
access_list = [
VMAccessInfo(
id=access.id,
user_id=access.user_id,
username=access.user.username,
vm_id=access.vm_id,
node=access.node,
vm_name=access.vm_name,
static_ip=access.static_ip,
rdp_username=access.rdp_username,
rdp_port=access.rdp_port,
is_active=access.is_active,
created_at=access.created_at
)
for access in accesses
]
return VMAccessListResponse(total=len(access_list), accesses=access_list)
@router.post("/vm-access", response_model=AdminResponse)
async def create_vm_access(
access_data: VMAccessCreate,
current_user: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db)
):
"""VM 접근 권한 부여"""
# 사용자 존재 확인
user = db.query(User).filter(User.id == access_data.user_id).first()
if not user:
raise NotFoundError("사용자를 찾을 수 없습니다")
# 중복 확인
existing = db.query(VMAccess).filter(
VMAccess.user_id == access_data.user_id,
VMAccess.vm_id == access_data.vm_id
).first()
if existing:
raise BadRequestError("이미 해당 사용자에게 VM 접근 권한이 있습니다")
# 권한 생성
access = VMAccess(
user_id=access_data.user_id,
vm_id=access_data.vm_id,
node=access_data.node,
static_ip=access_data.static_ip,
rdp_username=access_data.rdp_username,
rdp_password=access_data.rdp_password,
rdp_port=access_data.rdp_port,
is_active=True
)
db.add(access)
db.commit()
return AdminResponse(
success=True,
message=f"사용자 '{user.username}'에게 VM {access_data.vm_id} 접근 권한이 부여되었습니다"
)
@router.put("/vm-access/{access_id}", response_model=AdminResponse)
async def update_vm_access(
access_id: int,
access_data: VMAccessUpdate,
current_user: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db)
):
"""VM 접근 권한 수정"""
access = db.query(VMAccess).filter(VMAccess.id == access_id).first()
if not access:
raise NotFoundError("접근 권한을 찾을 수 없습니다")
# 정보 업데이트
if access_data.static_ip is not None:
access.static_ip = access_data.static_ip
if access_data.rdp_username is not None:
access.rdp_username = access_data.rdp_username
if access_data.rdp_password is not None:
access.rdp_password = access_data.rdp_password
if access_data.rdp_port is not None:
access.rdp_port = access_data.rdp_port
if access_data.is_active is not None:
access.is_active = access_data.is_active
db.commit()
return AdminResponse(
success=True,
message=f"VM {access.vm_id} 접근 권한이 수정되었습니다"
)
@router.delete("/vm-access/{access_id}", response_model=AdminResponse)
async def delete_vm_access(
access_id: int,
current_user: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db)
):
"""VM 접근 권한 삭제"""
access = db.query(VMAccess).filter(VMAccess.id == access_id).first()
if not access:
raise NotFoundError("접근 권한을 찾을 수 없습니다")
vm_id = access.vm_id
db.delete(access)
db.commit()
return AdminResponse(
success=True,
message=f"VM {vm_id} 접근 권한이 삭제되었습니다"
)

101
app/api/auth.py Normal file
View File

@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.auth import UserRegister, UserLogin, Token, UserResponse, CurrentUser
from app.services.auth_service import auth_service
from app.utils.jwt_handler import decode_token
from app.utils.exceptions import AuthenticationError
router = APIRouter()
security = HTTPBearer()
# 현재 사용자 가져오기 의존성
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> CurrentUser:
"""JWT 토큰에서 현재 사용자 정보 추출"""
token = credentials.credentials
payload = decode_token(token)
if not payload:
raise AuthenticationError("유효하지 않은 토큰입니다")
user_id = payload.get("sub")
if not user_id:
raise AuthenticationError("토큰 정보가 올바르지 않습니다")
user = auth_service.get_user_by_id(db, int(user_id))
if not user:
raise AuthenticationError("사용자를 찾을 수 없습니다")
if not user.is_active:
raise AuthenticationError("비활성화된 계정입니다")
return CurrentUser(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_data: UserRegister, db: Session = Depends(get_db)):
"""
회원가입
새로운 사용자 계정을 생성합니다.
"""
user = auth_service.register_user(db, user_data)
return user
@router.post("/login", response_model=Token)
async def login(login_data: UserLogin, db: Session = Depends(get_db)):
"""
로그인
사용자 인증 후 JWT 토큰을 발급합니다.
"""
user = auth_service.authenticate_user(db, login_data.username, login_data.password)
tokens = auth_service.create_tokens(user)
return tokens
@router.get("/me", response_model=UserResponse)
async def get_me(
current_user: CurrentUser = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
현재 사용자 정보 조회
JWT 토큰을 기반으로 현재 로그인한 사용자 정보를 반환합니다.
"""
user = auth_service.get_user_by_id(db, current_user.id)
return user
@router.post("/refresh", response_model=Token)
async def refresh_token(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
토큰 갱신
Refresh Token을 사용하여 새로운 Access Token을 발급합니다.
"""
token = credentials.credentials
payload = decode_token(token)
if not payload or payload.get("type") != "refresh":
raise AuthenticationError("유효하지 않은 Refresh Token입니다")
user_id = payload.get("sub")
user = auth_service.get_user_by_id(db, int(user_id))
if not user or not user.is_active:
raise AuthenticationError("사용자를 찾을 수 없습니다")
tokens = auth_service.create_tokens(user)
return tokens

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from datetime import datetime, timedelta
from app.api.auth import get_current_user
from app.schemas.auth import CurrentUser
from app.services.temp_ssh_password_service import temp_ssh_password_manager
import os
router = APIRouter()
class SshVerifyRequest(BaseModel):
"""SSH 비밀번호 검증 요청"""
username: str
password: str
@router.post("/credentials")
async def get_ssh_credentials(current_user: CurrentUser = Depends(get_current_user)):
"""
JWT 토큰으로 임시 SSH 자격증명 발급
Returns:
SSH 연결 정보 및 임시 비밀번호
"""
# 임시 비밀번호 생성 (1시간 유효)
temp_password = temp_ssh_password_manager.generate_password(
username=current_user.username,
validity_hours=1
)
# SSH 서버 정보 (외부 접속용)
ssh_host = os.getenv("SSH_HOST", "api.mouse84.com") # 외부 DDNS
ssh_port = int(os.getenv("SSH_PORT", "54054")) # 외부 포트 (내부 22로 포워딩)
# 만료 시간 계산
expires_at = datetime.utcnow() + timedelta(hours=1)
return {
"ssh_host": ssh_host,
"ssh_port": ssh_port,
"ssh_username": current_user.username,
"ssh_password": temp_password,
"expires_at": expires_at.isoformat(),
"expires_in_seconds": 3600
}
@router.post("/verify")
async def verify_ssh_password(request: SshVerifyRequest):
"""
임시 SSH 비밀번호 검증 (PAM 인증용)
Args:
request: 사용자명과 비밀번호
Returns:
200: 비밀번호 유효
401: 비밀번호 무효
"""
is_valid = temp_ssh_password_manager.verify_password(
username=request.username,
password=request.password
)
if is_valid:
return {"valid": True, "message": "Password verified"}
else:
raise HTTPException(status_code=401, detail="Invalid password")

127
app/api/tunnel.py Normal file
View File

@@ -0,0 +1,127 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.tunnel import (
TunnelCreateRequest,
TunnelCreateResponse,
TunnelStatusResponse,
TunnelCloseResponse,
TunnelInfo
)
from app.schemas.auth import CurrentUser
from app.api.auth import get_current_user
from app.services.ssh_tunnel_service import ssh_tunnel_manager
from app.services.proxmox_service import proxmox_service
from app.models.vm import VMAccess
from app.utils.exceptions import NotFoundError, BadRequestError
router = APIRouter()
@router.post("/create", response_model=TunnelCreateResponse)
async def create_tunnel(
request: TunnelCreateRequest,
current_user: CurrentUser = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
SSH 터널 생성
VM에 접속하기 위한 SSH Local Port Forwarding 터널을 생성합니다.
클라이언트는 반환된 local_port로 RDP 연결을 시도해야 합니다.
"""
# VM IP 주소 확인
# 1. 요청에 IP가 포함되어 있으면 사용 (Guest Agent 없이도 가능)
ip_address = request.vm_ip
# 2. 없으면 Guest Agent로 조회
if not ip_address:
try:
ip_address = await proxmox_service.get_vm_ip(request.node, request.vm_id)
if ip_address:
print(f"✅ Guest Agent에서 IP 조회 성공: {ip_address}")
except Exception as e:
print(f"⚠️ Guest Agent IP 조회 실패: {str(e)}")
ip_address = None
# 3. Guest Agent 실패 시 VMAccess 테이블의 static_ip 사용
if not ip_address:
vm_access = db.query(VMAccess).filter(
VMAccess.user_id == current_user.id,
VMAccess.vm_id == request.vm_id,
VMAccess.node == request.node
).first()
if vm_access and vm_access.static_ip:
ip_address = vm_access.static_ip
print(f"✅ Static IP 사용: {ip_address}")
else:
raise BadRequestError(
"VM의 IP 주소를 확인할 수 없습니다. "
"관리자 패널에서 고정 IP를 설정하거나 QEMU Guest Agent를 설치하세요."
)
try:
# SSH 터널 생성
tunnel_info = await ssh_tunnel_manager.create_tunnel(
user_id=current_user.id,
vm_id=request.vm_id,
remote_host=ip_address,
remote_port=3389 # RDP 포트
)
return TunnelCreateResponse(
success=True,
message="SSH 터널이 생성되었습니다",
session_id=tunnel_info["session_id"],
tunnel_info=TunnelInfo(
session_id=tunnel_info["session_id"],
local_port=tunnel_info["local_port"],
remote_host=tunnel_info["remote_host"],
remote_port=tunnel_info["remote_port"],
vm_id=request.vm_id,
is_active=True,
created_at=None # 자동 설정
)
)
except Exception as e:
return TunnelCreateResponse(
success=False,
message=f"터널 생성 실패: {str(e)}",
session_id="",
tunnel_info=None
)
@router.get("/{session_id}/status", response_model=TunnelStatusResponse)
async def get_tunnel_status(
session_id: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""
터널 상태 조회
활성 터널의 상태를 확인합니다.
"""
status = await ssh_tunnel_manager.get_tunnel_status(session_id)
if not status:
raise NotFoundError("터널을 찾을 수 없습니다")
return TunnelStatusResponse(**status)
@router.delete("/{session_id}", response_model=TunnelCloseResponse)
async def close_tunnel(
session_id: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""
터널 종료
SSH 터널을 종료하고 리소스를 정리합니다.
"""
success = await ssh_tunnel_manager.close_tunnel(session_id)
return TunnelCloseResponse(
success=success,
message="터널이 종료되었습니다" if success else "터널 종료에 실패했습니다",
session_id=session_id
)

141
app/api/vms.py Normal file
View File

@@ -0,0 +1,141 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.schemas.vm import VMInfo, VMListResponse, VMDetail, VMControlRequest, VMControlResponse
from app.schemas.auth import CurrentUser
from app.api.auth import get_current_user
from app.services.proxmox_service import proxmox_service
from app.models.vm import VMAccess
from app.utils.exceptions import NotFoundError, PermissionDeniedError
router = APIRouter()
@router.get("/my", response_model=VMListResponse)
async def get_my_vms(
current_user: CurrentUser = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
내 VM 목록 조회
현재 사용자가 접근 가능한 VM 목록을 반환합니다.
"""
# Proxmox에서 모든 VM 가져오기
all_vms = await proxmox_service.get_all_vms()
# 현재 사용자의 VMAccess 정보 조회
vm_accesses = db.query(VMAccess).filter(
VMAccess.user_id == current_user.id,
VMAccess.is_active == True
).all()
# vm_id를 키로 하는 딕셔너리 생성
access_map = {access.vm_id: access for access in vm_accesses}
vm_list = []
for vm in all_vms:
vm_id = vm["vmid"]
access = access_map.get(vm_id)
# VMAccess가 있으면 해당 정보 사용, 없으면 기본값
vm_info = VMInfo(
vm_id=vm_id,
node=vm["node"],
name=vm.get("name", "Unknown"),
status=vm.get("status", "unknown"),
ip_address=access.static_ip if access else None, # Static IP 자동 설정
cpus=vm.get("cpus", 0),
memory=vm.get("maxmem", 0) // (1024 * 1024), # bytes to MB
memory_usage=vm.get("mem", 0) // (1024 * 1024) if vm.get("mem") else None,
cpu_usage=vm.get("cpu", 0),
can_start=True,
can_stop=True,
can_reboot=True,
can_connect=True,
rdp_username=access.rdp_username if access else None, # RDP 사용자명
rdp_password=access.rdp_password if access else None, # RDP 비밀번호
rdp_port=access.rdp_port if access else 3389 # RDP 포트
)
vm_list.append(vm_info)
return VMListResponse(total=len(vm_list), vms=vm_list)
@router.get("/{vm_id}", response_model=VMDetail)
async def get_vm_detail(
vm_id: int,
node: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""
VM 상세 정보 조회
"""
# VM 상태 조회
status = await proxmox_service.get_vm_status(node, vm_id)
if not status:
raise NotFoundError(f"VM {vm_id}를 찾을 수 없습니다")
# IP 조회 제거 - 연결에 필요하지 않음
return VMDetail(
vm_id=vm_id,
node=node,
name=status.get("name", "Unknown"),
status=status.get("status", "unknown"),
ip_address=None, # IP 조회 안 함
cpus=status.get("cpus", 0),
memory=status.get("maxmem", 0) // (1024 * 1024),
memory_usage=status.get("mem", 0) // (1024 * 1024) if status.get("mem") else None,
cpu_usage=status.get("cpu", 0),
uptime=status.get("uptime"),
rdp_port=3389,
has_guest_agent=False # Guest Agent 불필요
)
@router.post("/{vm_id}/start", response_model=VMControlResponse)
async def start_vm(
vm_id: int,
node: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""VM 시작"""
success = await proxmox_service.start_vm(node, vm_id)
return VMControlResponse(
success=success,
message="VM이 시작되었습니다" if success else "VM 시작에 실패했습니다",
vm_id=vm_id,
action="start"
)
@router.post("/{vm_id}/stop", response_model=VMControlResponse)
async def stop_vm(
vm_id: int,
node: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""VM 종료"""
success = await proxmox_service.stop_vm(node, vm_id)
return VMControlResponse(
success=success,
message="VM이 종료되었습니다" if success else "VM 종료에 실패했습니다",
vm_id=vm_id,
action="stop"
)
@router.post("/{vm_id}/reboot", response_model=VMControlResponse)
async def reboot_vm(
vm_id: int,
node: str,
current_user: CurrentUser = Depends(get_current_user)
):
"""VM 재시작"""
success = await proxmox_service.reboot_vm(node, vm_id)
return VMControlResponse(
success=success,
message="VM이 재시작되었습니다" if success else "VM 재시작에 실패했습니다",
vm_id=vm_id,
action="reboot"
)