v0.0.25 | KVM 서버 관리 API 추가
All checks were successful
Build And Test / build-and-push (push) Successful in 2m7s

- KVMServer 모델 추가 (멀티 서버 지원)
- 서버별 SSH 키 암호화 저장
- msa-django-libvirt 연동용 SSH 정보 조회 API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 11:14:56 +09:00
parent 85f5688a0b
commit 2c050829ff
6 changed files with 494 additions and 4 deletions

View File

@ -0,0 +1,41 @@
# Generated by Django 4.2.14 on 2026-01-15 14:49
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0011_add_dns_appkey_to_nhncloudproject'),
]
operations = [
migrations.CreateModel(
name='KVMServer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='서버 별칭')),
('host', models.CharField(max_length=255, verbose_name='호스트 (IP 또는 도메인)')),
('port', models.IntegerField(default=22, verbose_name='SSH 포트')),
('username', models.CharField(max_length=100, verbose_name='SSH 사용자명')),
('encrypted_private_key_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='SSH 키 이름')),
('encrypted_private_key', models.BinaryField(blank=True, null=True, verbose_name='SSH 개인키 (암호화)')),
('libvirt_uri', models.CharField(blank=True, help_text='예: qemu+ssh://user@host/system', max_length=255, null=True, verbose_name='Libvirt URI')),
('description', models.TextField(blank=True, null=True, verbose_name='설명')),
('tags', models.CharField(blank=True, help_text='쉼표로 구분된 태그 목록', max_length=500, null=True, verbose_name='태그')),
('is_active', models.BooleanField(default=True, verbose_name='활성화 상태')),
('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='마지막 사용 시각')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kvm_servers', to=settings.AUTH_USER_MODEL, verbose_name='사용자')),
],
options={
'verbose_name': 'KVM 서버',
'verbose_name_plural': 'KVM 서버',
'ordering': ['-is_active', '-created_at'],
'unique_together': {('user', 'host', 'port')},
},
),
]

View File

@ -198,3 +198,97 @@ class NHNCloudProject(models.Model):
"""자격증명 저장 (비밀번호 암호화)"""
self.encrypted_password = self.encrypt_password(password)
self.save()
# ============================================
# KVM 서버 관리 (멀티 서버 지원)
# ============================================
class KVMServer(models.Model):
"""
사용자별 KVM 서버 관리 (멀티 서버 지원)
msa-django-libvirt에서 SSH 접속 정보를 요청할 때 사용
"""
user = models.ForeignKey(
CustomUser,
on_delete=models.CASCADE,
related_name='kvm_servers',
verbose_name="사용자"
)
name = models.CharField(max_length=100, verbose_name="서버 별칭")
host = models.CharField(max_length=255, verbose_name="호스트 (IP 또는 도메인)")
port = models.IntegerField(default=22, verbose_name="SSH 포트")
username = models.CharField(max_length=100, verbose_name="SSH 사용자명")
encrypted_private_key_name = models.CharField(
max_length=100, blank=True, null=True, verbose_name="SSH 키 이름"
)
encrypted_private_key = models.BinaryField(
blank=True, null=True, verbose_name="SSH 개인키 (암호화)"
)
libvirt_uri = models.CharField(
max_length=255, blank=True, null=True,
verbose_name="Libvirt URI",
help_text="예: qemu+ssh://user@host/system"
)
description = models.TextField(blank=True, null=True, verbose_name="설명")
tags = models.CharField(
max_length=500, blank=True, null=True,
verbose_name="태그",
help_text="쉼표로 구분된 태그 목록"
)
is_active = models.BooleanField(default=True, verbose_name="활성화 상태")
last_used_at = models.DateTimeField(blank=True, null=True, verbose_name="마지막 사용 시각")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "KVM 서버"
verbose_name_plural = "KVM 서버"
unique_together = ['user', 'host', 'port'] # 동일 사용자가 같은 호스트:포트 중복 등록 방지
ordering = ['-is_active', '-created_at']
def __str__(self):
return f"{self.name} ({self.host}:{self.port})"
def get_encryption_key(self) -> bytes:
"""SECRET_KEY 기반 Fernet 키 생성"""
hashed = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
return base64.urlsafe_b64encode(hashed[:32])
def encrypt_private_key(self, private_key: str) -> bytes:
"""SSH 개인키 암호화"""
cipher = Fernet(self.get_encryption_key())
return cipher.encrypt(private_key.encode())
def decrypt_private_key(self) -> str:
"""SSH 개인키 복호화"""
if self.encrypted_private_key:
cipher = Fernet(self.get_encryption_key())
decrypted = cipher.decrypt(self.encrypted_private_key).decode()
self.last_used_at = timezone.now()
self.save(update_fields=['last_used_at'])
return decrypted
return ""
def save_ssh_key(self, private_key: str, key_name: str = None):
"""SSH 키 저장 (암호화)"""
self.encrypted_private_key = self.encrypt_private_key(private_key)
if key_name:
self.encrypted_private_key_name = key_name
self.save()
def get_tags_list(self) -> list:
"""태그 문자열을 리스트로 반환"""
if self.tags:
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
return []
def set_tags_list(self, tags_list: list):
"""태그 리스트를 문자열로 저장"""
self.tags = ', '.join(tags_list)
def get_libvirt_uri(self) -> str:
"""Libvirt URI 반환 (없으면 기본값 생성)"""
if self.libvirt_uri:
return self.libvirt_uri
return f"qemu+ssh://{self.username}@{self.host}:{self.port}/system"

View File

@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import CustomUser
from .models import CustomUser, KVMServer
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework.exceptions import ValidationError
@ -86,3 +86,60 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
data["email"] = user.email
data["grade"] = user.grade
return data
class KVMServerSerializer(serializers.ModelSerializer):
"""KVM 서버 시리얼라이저"""
tags_list = serializers.ListField(
child=serializers.CharField(),
required=False,
write_only=True
)
private_key = serializers.CharField(write_only=True, required=False)
class Meta:
model = KVMServer
fields = [
'id', 'name', 'host', 'port', 'username',
'encrypted_private_key_name', 'libvirt_uri',
'description', 'tags', 'tags_list', 'is_active',
'last_used_at', 'created_at', 'updated_at', 'private_key'
]
read_only_fields = ['id', 'last_used_at', 'created_at', 'updated_at']
extra_kwargs = {
'tags': {'required': False},
}
def to_representation(self, instance):
data = super().to_representation(instance)
data['has_ssh_key'] = bool(instance.encrypted_private_key)
data['tags_list'] = instance.get_tags_list()
return data
def create(self, validated_data):
tags_list = validated_data.pop('tags_list', None)
private_key = validated_data.pop('private_key', None)
instance = super().create(validated_data)
if tags_list is not None:
instance.set_tags_list(tags_list)
if private_key:
instance.save_ssh_key(private_key, validated_data.get('encrypted_private_key_name'))
instance.save()
return instance
def update(self, instance, validated_data):
tags_list = validated_data.pop('tags_list', None)
private_key = validated_data.pop('private_key', None)
instance = super().update(instance, validated_data)
if tags_list is not None:
instance.set_tags_list(tags_list)
instance.save()
if private_key:
instance.save_ssh_key(private_key, validated_data.get('encrypted_private_key_name'))
return instance

View File

@ -7,6 +7,9 @@ from .views import (
# NHN Cloud 멀티 프로젝트 지원
NHNCloudProjectListView, NHNCloudProjectDetailView,
NHNCloudProjectActivateView, NHNCloudProjectPasswordView,
# KVM 서버 관리
KVMServerListView, KVMServerDetailView,
KVMServerActivateView, KVMServerSSHKeyView, KVMServerSSHKeyUploadView,
)
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
from .views_jwks import jwks_view # django-jwks
@ -33,4 +36,10 @@ urlpatterns = [
path('nhn-cloud/projects/<int:project_id>/', NHNCloudProjectDetailView.as_view(), name='nhn_cloud_project_detail'),
path('nhn-cloud/projects/<int:project_id>/activate/', NHNCloudProjectActivateView.as_view(), name='nhn_cloud_project_activate'),
path('nhn-cloud/projects/<int:project_id>/password/', NHNCloudProjectPasswordView.as_view(), name='nhn_cloud_project_password'),
# KVM 서버 관리 API (멀티 서버 지원)
path('kvm-servers/', KVMServerListView.as_view(), name='kvm_server_list'),
path('kvm-servers/<int:server_id>/', KVMServerDetailView.as_view(), name='kvm_server_detail'),
path('kvm-servers/<int:server_id>/activate/', KVMServerActivateView.as_view(), name='kvm_server_activate'),
path('kvm-servers/<int:server_id>/ssh-key/', KVMServerSSHKeyView.as_view(), name='kvm_server_ssh_key'),
path('kvm-servers/<int:server_id>/ssh-key/upload/', KVMServerSSHKeyUploadView.as_view(), name='kvm_server_ssh_key_upload'),
]

View File

@ -7,8 +7,8 @@ from rest_framework import status
from rest_framework.permissions import IsAuthenticated, BasePermission
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework import generics
from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer, UserListSerializer
from .models import CustomUser, NHNCloudProject
from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer, UserListSerializer, KVMServerSerializer
from .models import CustomUser, NHNCloudProject, KVMServer
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__) # ✅ 트레이서 생성
@ -740,3 +740,292 @@ class NHNCloudProjectPasswordView(APIView):
f"[NHN PROJECT PASSWORD] user={email} | status=fail | reason=exception | IP={ip} | UA={ua}"
)
return Response({"error": f"복호화 실패: {str(e)}"}, status=500)
# ============================================
# KVM 서버 관리 API (멀티 서버 지원)
# ============================================
class KVMServerListView(APIView):
"""KVM 서버 목록 조회 및 추가"""
permission_classes = [IsAuthenticated]
def get(self, request):
"""서버 목록 조회"""
with tracer.start_as_current_span("KVMServerListView GET") as span:
email, ip, ua = get_request_info(request)
servers = KVMServer.objects.filter(user=request.user)
logger.debug(f"[KVM SERVER LIST] user={email} | count={servers.count()} | IP={ip} | UA={ua}")
span.add_event("KVM servers listed", attributes={"email": email, "count": servers.count()})
serializer = KVMServerSerializer(servers, many=True)
return Response(serializer.data)
def post(self, request):
"""서버 추가"""
with tracer.start_as_current_span("KVMServerListView POST") as span:
email, ip, ua = get_request_info(request)
# 중복 체크
host = request.data.get("host")
port = request.data.get("port", 22)
if KVMServer.objects.filter(user=request.user, host=host, port=port).exists():
logger.warning(
f"[KVM SERVER CREATE] user={email} | status=fail | reason=duplicate | IP={ip} | UA={ua}"
)
return Response(
{"error": "이미 등록된 서버입니다 (동일한 호스트:포트)."},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = KVMServerSerializer(data=request.data)
if serializer.is_valid():
server = serializer.save(user=request.user)
logger.info(
f"[KVM SERVER CREATE] user={email} | server={server.name} | host={server.host} | IP={ip} | UA={ua}"
)
span.add_event("KVM server created", attributes={"email": email, "server": server.name})
return Response(KVMServerSerializer(server).data, status=status.HTTP_201_CREATED)
logger.warning(
f"[KVM SERVER CREATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}"
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class KVMServerDetailView(APIView):
"""KVM 서버 상세 (조회/수정/삭제)"""
permission_classes = [IsAuthenticated]
def get_server(self, request, server_id):
"""서버 조회 (본인 것만)"""
try:
return KVMServer.objects.get(id=server_id, user=request.user)
except KVMServer.DoesNotExist:
return None
def get(self, request, server_id):
"""서버 상세 조회"""
with tracer.start_as_current_span("KVMServerDetailView GET") as span:
email, ip, ua = get_request_info(request)
server = self.get_server(request, server_id)
if not server:
return Response({"error": "서버를 찾을 수 없습니다."}, status=404)
span.add_event("KVM server retrieved", attributes={"email": email, "server": server.name})
return Response(KVMServerSerializer(server).data)
def put(self, request, server_id):
"""서버 수정"""
with tracer.start_as_current_span("KVMServerDetailView PUT") as span:
email, ip, ua = get_request_info(request)
server = self.get_server(request, server_id)
if not server:
logger.warning(
f"[KVM SERVER UPDATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}"
)
return Response({"error": "서버를 찾을 수 없습니다."}, status=404)
# 호스트:포트 중복 체크 (자기 자신 제외)
host = request.data.get("host", server.host)
port = request.data.get("port", server.port)
if KVMServer.objects.filter(
user=request.user, host=host, port=port
).exclude(id=server_id).exists():
return Response(
{"error": "이미 등록된 서버입니다 (동일한 호스트:포트)."},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = KVMServerSerializer(server, data=request.data, partial=True)
if serializer.is_valid():
server = serializer.save()
logger.info(
f"[KVM SERVER UPDATE] user={email} | server={server.name} | IP={ip} | UA={ua}"
)
span.add_event("KVM server updated", attributes={"email": email, "server": server.name})
return Response(KVMServerSerializer(server).data)
logger.warning(
f"[KVM SERVER UPDATE] user={email} | status=fail | reason=validation | IP={ip} | UA={ua}"
)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, server_id):
"""서버 삭제"""
with tracer.start_as_current_span("KVMServerDetailView DELETE") as span:
email, ip, ua = get_request_info(request)
server = self.get_server(request, server_id)
if not server:
logger.warning(
f"[KVM SERVER DELETE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}"
)
return Response({"error": "서버를 찾을 수 없습니다."}, status=404)
server_name = server.name
server.delete()
logger.info(
f"[KVM SERVER DELETE] user={email} | server={server_name} | IP={ip} | UA={ua}"
)
span.add_event("KVM server deleted", attributes={"email": email, "server": server_name})
return Response(status=status.HTTP_204_NO_CONTENT)
class KVMServerActivateView(APIView):
"""KVM 서버 활성화/비활성화"""
permission_classes = [IsAuthenticated]
def patch(self, request, server_id):
"""서버 활성화 상태 토글"""
with tracer.start_as_current_span("KVMServerActivateView PATCH") as span:
email, ip, ua = get_request_info(request)
try:
server = KVMServer.objects.get(id=server_id, user=request.user)
except KVMServer.DoesNotExist:
logger.warning(
f"[KVM SERVER ACTIVATE] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}"
)
return Response({"error": "서버를 찾을 수 없습니다."}, status=404)
# 요청에서 is_active 값 가져오기 (없으면 토글)
is_active = request.data.get('is_active')
if is_active is None:
server.is_active = not server.is_active
else:
server.is_active = is_active
server.save(update_fields=['is_active'])
action = "activated" if server.is_active else "deactivated"
logger.info(
f"[KVM SERVER ACTIVATE] user={email} | server={server.name} | action={action} | IP={ip} | UA={ua}"
)
span.add_event(f"KVM server {action}", attributes={"email": email, "server": server.name})
return Response({
"message": f"서버가 {'활성화' if server.is_active else '비활성화'}되었습니다.",
"id": server.id,
"name": server.name,
"is_active": server.is_active,
})
class KVMServerSSHKeyView(APIView):
"""KVM 서버 SSH 키 조회 (복호화) - msa-django-libvirt에서 사용"""
permission_classes = [IsAuthenticated]
def get(self, request, server_id):
"""복호화된 SSH 키와 접속 정보 조회"""
with tracer.start_as_current_span("KVMServerSSHKeyView GET") as span:
email, ip, ua = get_request_info(request)
try:
server = KVMServer.objects.get(id=server_id, user=request.user)
except KVMServer.DoesNotExist:
logger.warning(
f"[KVM SERVER SSH KEY] user={email} | status=fail | reason=not_found | IP={ip} | UA={ua}"
)
return Response({"error": "서버를 찾을 수 없습니다."}, status=404)
if not server.encrypted_private_key:
logger.warning(
f"[KVM SERVER SSH KEY] user={email} | server={server.name} | status=fail | reason=no_key | IP={ip} | UA={ua}"
)
return Response(
{"error": "SSH 키가 등록되어 있지 않습니다."},
status=404
)
try:
decrypted_key = server.decrypt_private_key()
logger.info(
f"[KVM SERVER SSH KEY] user={email} | server={server.name} | IP={ip} | UA={ua}"
)
span.add_event("KVM server SSH key retrieved", attributes={"email": email, "server": server.name})
return Response({
"id": server.id,
"name": server.name,
"host": server.host,
"port": server.port,
"username": server.username,
"private_key": decrypted_key,
"libvirt_uri": server.get_libvirt_uri(),
})
except Exception as e:
logger.exception(
f"[KVM SERVER SSH KEY] user={email} | server={server.name} | status=fail | reason=exception | IP={ip} | UA={ua}"
)
return Response({"error": f"복호화 실패: {str(e)}"}, status=500)
class KVMServerSSHKeyUploadView(APIView):
"""KVM 서버 SSH 키 업로드/삭제"""
permission_classes = [IsAuthenticated]
def get_server(self, request, server_id):
try:
return KVMServer.objects.get(id=server_id, user=request.user)
except KVMServer.DoesNotExist:
return None
def post(self, request, server_id):
"""SSH 키 업로드"""
with tracer.start_as_current_span("KVMServerSSHKeyUploadView POST") as span:
email, ip, ua = get_request_info(request)
server = self.get_server(request, server_id)
if not server:
return Response({"error": "서버를 찾을 수 없습니다."}, status=404)
private_key = request.data.get("private_key")
key_name = request.data.get("key_name")
if not private_key:
logger.warning(
f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | status=fail | reason=missing_key | IP={ip} | UA={ua}"
)
return Response(
{"error": "private_key는 필수입니다."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
server.save_ssh_key(private_key, key_name)
logger.info(
f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | IP={ip} | UA={ua}"
)
span.add_event("KVM server SSH key uploaded", attributes={"email": email, "server": server.name})
return Response({"message": "SSH 키가 저장되었습니다."}, status=201)
except Exception as e:
logger.exception(
f"[KVM SERVER SSH UPLOAD] user={email} | server={server.name} | status=fail | reason=exception | IP={ip} | UA={ua}"
)
return Response({"error": f"저장 실패: {str(e)}"}, status=500)
def delete(self, request, server_id):
"""SSH 키 삭제"""
with tracer.start_as_current_span("KVMServerSSHKeyUploadView DELETE") as span:
email, ip, ua = get_request_info(request)
server = self.get_server(request, server_id)
if not server:
return Response({"error": "서버를 찾을 수 없습니다."}, status=404)
server.encrypted_private_key = None
server.encrypted_private_key_name = None
server.last_used_at = None
server.save(update_fields=['encrypted_private_key', 'encrypted_private_key_name', 'last_used_at'])
logger.info(
f"[KVM SERVER SSH DELETE] user={email} | server={server.name} | IP={ip} | UA={ua}"
)
span.add_event("KVM server SSH key deleted", attributes={"email": email, "server": server.name})
return Response({"message": "SSH 키가 삭제되었습니다."})