diff --git a/users/migrations/0012_add_kvm_server.py b/users/migrations/0012_add_kvm_server.py new file mode 100644 index 0000000..43d0a60 --- /dev/null +++ b/users/migrations/0012_add_kvm_server.py @@ -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')}, + }, + ), + ] diff --git a/users/models.py b/users/models.py index 86604d9..9d97e2a 100644 --- a/users/models.py +++ b/users/models.py @@ -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" diff --git a/users/serializers.py b/users/serializers.py index caf64d7..24f7a38 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -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 diff --git a/users/urls.py b/users/urls.py index 2587c33..2052256 100644 --- a/users/urls.py +++ b/users/urls.py @@ -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//', NHNCloudProjectDetailView.as_view(), name='nhn_cloud_project_detail'), path('nhn-cloud/projects//activate/', NHNCloudProjectActivateView.as_view(), name='nhn_cloud_project_activate'), path('nhn-cloud/projects//password/', NHNCloudProjectPasswordView.as_view(), name='nhn_cloud_project_password'), + # KVM 서버 관리 API (멀티 서버 지원) + path('kvm-servers/', KVMServerListView.as_view(), name='kvm_server_list'), + path('kvm-servers//', KVMServerDetailView.as_view(), name='kvm_server_detail'), + path('kvm-servers//activate/', KVMServerActivateView.as_view(), name='kvm_server_activate'), + path('kvm-servers//ssh-key/', KVMServerSSHKeyView.as_view(), name='kvm_server_ssh_key'), + path('kvm-servers//ssh-key/upload/', KVMServerSSHKeyUploadView.as_view(), name='kvm_server_ssh_key_upload'), ] diff --git a/users/views.py b/users/views.py index 4f75685..ba5692e 100644 --- a/users/views.py +++ b/users/views.py @@ -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 키가 삭제되었습니다."}) diff --git a/version b/version index 9aaae2c..b484bb0 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.0.23 +v0.0.25 \ No newline at end of file