diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py
index 954fb6074..2158a25f1 100644
--- a/apps/authentication/api/connection_token.py
+++ b/apps/authentication/api/connection_token.py
@@ -33,7 +33,7 @@ from ..models import ConnectionToken, date_expired_default
 from ..serializers import (
     ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
     SuperConnectionTokenSerializer, ConnectTokenAppletOptionSerializer,
-    ConnectionTokenReusableSerializer,
+    ConnectionTokenReusableSerializer, ConnectTokenVirtualAppOptionSerializer
 )
 
 __all__ = ['ConnectionTokenViewSet', 'SuperConnectionTokenViewSet']
@@ -464,6 +464,7 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
         'get_secret_detail': 'authentication.view_superconnectiontokensecret',
         'get_applet_info': 'authentication.view_superconnectiontoken',
         'release_applet_account': 'authentication.view_superconnectiontoken',
+        'get_virtual_app_info': 'authentication.view_superconnectiontoken',
     }
 
     def get_queryset(self):
@@ -529,6 +530,16 @@ class SuperConnectionTokenViewSet(ConnectionTokenViewSet):
         serializer = ConnectTokenAppletOptionSerializer(data)
         return Response(serializer.data)
 
+    @action(methods=['POST'], detail=False, url_path='virtual-app-option')
+    def get_virtual_app_info(self, *args, **kwargs):
+        token_id = self.request.data.get('id')
+        token = get_object_or_404(ConnectionToken, pk=token_id)
+        if token.is_expired:
+            return Response({'error': 'Token expired'}, status=status.HTTP_400_BAD_REQUEST)
+        data = token.get_virtual_app_option()
+        serializer = ConnectTokenVirtualAppOptionSerializer(data)
+        return Response(serializer.data)
+
     @action(methods=['DELETE', 'POST'], detail=False, url_path='applet-account/release')
     def release_applet_account(self, *args, **kwargs):
         account_id = self.request.data.get('id')
diff --git a/apps/authentication/models/connection_token.py b/apps/authentication/models/connection_token.py
index 48c8e8e16..6cd5648db 100644
--- a/apps/authentication/models/connection_token.py
+++ b/apps/authentication/models/connection_token.py
@@ -18,7 +18,7 @@ from common.utils import lazyproperty, pretty_string, bulk_get
 from common.utils.timezone import as_current_tz
 from orgs.mixins.models import JMSOrgBaseModel
 from orgs.utils import tmp_to_org
-from terminal.models import Applet
+from terminal.models import Applet, VirtualApp
 
 
 def date_expired_default():
@@ -177,6 +177,15 @@ class ConnectionToken(JMSOrgBaseModel):
         }
         return options
 
+    def get_virtual_app_option(self):
+        method = self.connect_method_object
+        if not method or method.get('type') != 'virtual_app' or method.get('disabled', False):
+            return None
+        virtual_app = VirtualApp.objects.filter(name=method.get('value')).first()
+        if not virtual_app:
+            return None
+        return virtual_app
+
     def get_applet_option(self):
         method = self.connect_method_object
         if not method or method.get('type') != 'applet' or method.get('disabled', False):
diff --git a/apps/authentication/serializers/connect_token_secret.py b/apps/authentication/serializers/connect_token_secret.py
index 6685316bd..3eea79e4e 100644
--- a/apps/authentication/serializers/connect_token_secret.py
+++ b/apps/authentication/serializers/connect_token_secret.py
@@ -15,7 +15,8 @@ from users.models import User
 from ..models import ConnectionToken
 
 __all__ = [
-    'ConnectionTokenSecretSerializer', 'ConnectTokenAppletOptionSerializer'
+    'ConnectionTokenSecretSerializer', 'ConnectTokenAppletOptionSerializer',
+    'ConnectTokenVirtualAppOptionSerializer',
 ]
 
 
@@ -161,3 +162,10 @@ class ConnectTokenAppletOptionSerializer(serializers.Serializer):
     account = _ConnectionTokenAccountSerializer(read_only=True)
     gateway = _ConnectionTokenGatewaySerializer(read_only=True)
     remote_app_option = serializers.JSONField(read_only=True)
+
+
+class ConnectTokenVirtualAppOptionSerializer(serializers.Serializer):
+    name = serializers.CharField(label=_('Name'))
+    image_name = serializers.CharField(label=_('Image name'))
+    image_port = serializers.IntegerField(label=_('Image port'))
+    image_protocol = serializers.CharField(label=_('Image protocol'))
diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py
index 45d21aeab..7492f5c4d 100644
--- a/apps/jumpserver/conf.py
+++ b/apps/jumpserver/conf.py
@@ -598,6 +598,7 @@ class Config(dict):
         'GPT_BASE_URL': '',
         'GPT_PROXY': '',
         'GPT_MODEL': 'gpt-3.5-turbo',
+        'VIRTUAL_APP_ENABLED': False,
     }
 
     old_config_map = {
diff --git a/apps/jumpserver/rewriting/storage/permissions.py b/apps/jumpserver/rewriting/storage/permissions.py
index 7af0adece..511545865 100644
--- a/apps/jumpserver/rewriting/storage/permissions.py
+++ b/apps/jumpserver/rewriting/storage/permissions.py
@@ -5,6 +5,7 @@ path_perms_map = {
     'settings': '*',
     'replay': 'default',
     'applets': 'terminal.view_applet',
+    'virtual_apps': 'terminal.view_virtualapp',
     'playbooks': 'ops.view_playbook'
 }
 
diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py
index fa83fa9fd..d9eb41332 100644
--- a/apps/jumpserver/settings/custom.py
+++ b/apps/jumpserver/settings/custom.py
@@ -219,3 +219,5 @@ GPT_API_KEY = CONFIG.GPT_API_KEY
 GPT_BASE_URL = CONFIG.GPT_BASE_URL
 GPT_PROXY = CONFIG.GPT_PROXY
 GPT_MODEL = CONFIG.GPT_MODEL
+
+VIRTUAL_APP_ENABLED = CONFIG.VIRTUAL_APP_ENABLED
diff --git a/apps/settings/serializers/public.py b/apps/settings/serializers/public.py
index 1b5c78609..21dc2c2f8 100644
--- a/apps/settings/serializers/public.py
+++ b/apps/settings/serializers/public.py
@@ -53,6 +53,7 @@ class PrivateSettingSerializer(PublicSettingSerializer):
     CONNECTION_TOKEN_REUSABLE = serializers.BooleanField()
     CACHE_LOGIN_PASSWORD_ENABLED = serializers.BooleanField()
     VAULT_ENABLED = serializers.BooleanField()
+    VIRTUAL_APP_ENABLED = serializers.BooleanField()
 
 
 class ServerInfoSerializer(serializers.Serializer):
diff --git a/apps/terminal/api/__init__.py b/apps/terminal/api/__init__.py
index c4a60efb6..b6afedd51 100644
--- a/apps/terminal/api/__init__.py
+++ b/apps/terminal/api/__init__.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 #
-from .session import *
-from .component import *
 from .applet import *
+from .component import *
 from .db_listen_port import *
+from .session import *
+from .virtualapp import *
diff --git a/apps/terminal/api/virtualapp/__init__.py b/apps/terminal/api/virtualapp/__init__.py
new file mode 100644
index 000000000..0c67de93f
--- /dev/null
+++ b/apps/terminal/api/virtualapp/__init__.py
@@ -0,0 +1,3 @@
+from .provider import *
+from .relation import *
+from .virtualapp import *
diff --git a/apps/terminal/api/virtualapp/provider.py b/apps/terminal/api/virtualapp/provider.py
new file mode 100644
index 000000000..fbdf11ecc
--- /dev/null
+++ b/apps/terminal/api/virtualapp/provider.py
@@ -0,0 +1,63 @@
+from django.core.cache import cache
+from rest_framework.decorators import action
+from rest_framework.exceptions import ValidationError
+from rest_framework.response import Response
+
+from common.api import JMSBulkModelViewSet
+from common.permissions import IsServiceAccount
+from orgs.utils import tmp_to_builtin_org
+from terminal.models import AppProvider
+from terminal.serializers import (
+    AppProviderSerializer, AppProviderContainerSerializer
+)
+
+__all__ = ['AppProviderViewSet', ]
+
+
+class AppProviderViewSet(JMSBulkModelViewSet):
+    serializer_class = AppProviderSerializer
+    queryset = AppProvider.objects.all()
+    search_fields = ['name', 'hostname', ]
+    rbac_perms = {
+        'containers': 'terminal.view_appprovider',
+        'status': 'terminal.view_appprovider',
+    }
+
+    cache_status_key_prefix = 'virtual_host_{}_status'
+
+    def dispatch(self, request, *args, **kwargs):
+        with tmp_to_builtin_org(system=1):
+            return super().dispatch(request, *args, **kwargs)
+
+    def get_permissions(self):
+        if self.action == 'create':
+            return [IsServiceAccount()]
+        return super().get_permissions()
+
+    def perform_create(self, serializer):
+        request_terminal = getattr(self.request.user, 'terminal', None)
+        if not request_terminal:
+            raise ValidationError('Request user has no terminal')
+        data = dict()
+        data['terminal'] = request_terminal
+        data['id'] = self.request.user.id
+        serializer.save(**data)
+
+    @action(detail=True, methods=['get'], serializer_class=AppProviderContainerSerializer)
+    def containers(self, request, *args, **kwargs):
+        instance = self.get_object()
+        key = self.cache_status_key_prefix.format(instance.id)
+        data = cache.get(key)
+        if not data:
+            data = []
+        return self.get_paginated_response_from_queryset(data)
+
+    @action(detail=True, methods=['post'], serializer_class=AppProviderContainerSerializer)
+    def status(self, request, *args, **kwargs):
+        instance = self.get_object()
+        serializer = self.get_serializer(data=request.data, many=True)
+        serializer.is_valid(raise_exception=True)
+        validated_data = serializer.validated_data
+        key = self.cache_status_key_prefix.format(instance.id)
+        cache.set(key, validated_data, 60 * 3)
+        return Response({'msg': 'ok'})
diff --git a/apps/terminal/api/virtualapp/relation.py b/apps/terminal/api/virtualapp/relation.py
new file mode 100644
index 000000000..9c15a0e0d
--- /dev/null
+++ b/apps/terminal/api/virtualapp/relation.py
@@ -0,0 +1,64 @@
+from typing import Callable
+
+from django.conf import settings
+from django.shortcuts import get_object_or_404
+from rest_framework.request import Request
+
+from common.api import JMSModelViewSet
+from common.permissions import IsServiceAccount
+from common.utils import is_uuid
+from rbac.permissions import RBACPermission
+from terminal.models import AppProvider
+from terminal.serializers import (
+    VirtualAppPublicationSerializer
+)
+
+
+class ProviderMixin:
+    request: Request
+    permission_denied: Callable
+    kwargs: dict
+    rbac_perms = (
+        ('list', 'terminal.view_appprovider'),
+        ('retrieve', 'terminal.view_appprovider'),
+    )
+
+    def get_permissions(self):
+        if self.kwargs.get('host') and settings.DEBUG:
+            return [RBACPermission()]
+        else:
+            return [IsServiceAccount()]
+
+    def self_provider(self):
+        try:
+            return self.request.user.terminal.app_provider
+        except AttributeError:
+            raise self.permission_denied(self.request, 'User has no app provider')
+
+    def pk_provider(self):
+        return get_object_or_404(AppProvider, id=self.kwargs.get('provider'))
+
+    @property
+    def provider(self):
+        if self.kwargs.get('provider'):
+            host = self.pk_provider()
+        else:
+            host = self.self_provider()
+        return host
+
+
+class AppProviderAppViewSet(ProviderMixin, JMSModelViewSet):
+    provider: AppProvider
+    serializer_class = VirtualAppPublicationSerializer
+    filterset_fields = ['provider__name', 'app__name', 'status']
+
+    def get_object(self):
+        pk = self.kwargs.get('pk')
+        if not is_uuid(pk):
+            return self.provider.publications.get(app__name=pk)
+        else:
+            return self.provider.publications.get(id=pk)
+
+    def get_queryset(self):
+        queryset = self.provider.publications.all()
+        return queryset
diff --git a/apps/terminal/api/virtualapp/virtualapp.py b/apps/terminal/api/virtualapp/virtualapp.py
new file mode 100644
index 000000000..07b92a356
--- /dev/null
+++ b/apps/terminal/api/virtualapp/virtualapp.py
@@ -0,0 +1,77 @@
+import os.path
+import shutil
+import zipfile
+from typing import Callable
+
+from django.core.files.storage import default_storage
+from django.utils._os import safe_join
+from django.utils.translation import gettext as _
+from rest_framework import viewsets
+from rest_framework.decorators import action
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import ValidationError
+
+from common.api import JMSBulkModelViewSet
+from common.serializers import FileSerializer
+from terminal import serializers
+from terminal.models import VirtualAppPublication, VirtualApp
+
+__all__ = ['VirtualAppViewSet', 'VirtualAppPublicationViewSet']
+
+
+class UploadMixin:
+    get_serializer: Callable
+    request: Request
+    get_object: Callable
+
+    def extract_zip_pkg(self):
+        serializer = self.get_serializer(data=self.request.data)
+        serializer.is_valid(raise_exception=True)
+        file = serializer.validated_data['file']
+        save_to = 'virtual_apps/{}'.format(file.name + '.tmp.zip')
+        if default_storage.exists(save_to):
+            default_storage.delete(save_to)
+        rel_path = default_storage.save(save_to, file)
+        path = default_storage.path(rel_path)
+        extract_to = default_storage.path('virtual_apps/{}.tmp'.format(file.name))
+        if os.path.exists(extract_to):
+            shutil.rmtree(extract_to)
+        try:
+            with zipfile.ZipFile(path) as zp:
+                if zp.testzip() is not None:
+                    raise ValidationError({'error': _('Invalid zip file')})
+                zp.extractall(extract_to)
+        except RuntimeError as e:
+            raise ValidationError({'error': _('Invalid zip file') + ': {}'.format(e)})
+        tmp_dir = safe_join(extract_to, file.name.replace('.zip', ''))
+        return tmp_dir
+
+    @action(detail=False, methods=['post'], serializer_class=FileSerializer)
+    def upload(self, request, *args, **kwargs):
+        tmp_dir = self.extract_zip_pkg()
+        manifest = VirtualApp.validate_pkg(tmp_dir)
+        name = manifest['name']
+        instance = VirtualApp.objects.filter(name=name).first()
+        if instance:
+            return Response({'error': 'virtual app already exists: {}'.format(name)}, status=400)
+
+        app, serializer = VirtualApp.install_from_dir(tmp_dir)
+        return Response(serializer.data, status=201)
+
+
+class VirtualAppViewSet(UploadMixin, JMSBulkModelViewSet):
+    queryset = VirtualApp.objects.all()
+    serializer_class = serializers.VirtualAppSerializer
+    filterset_fields = ['name', 'image_name', 'is_active']
+    search_fields = ['name', ]
+    rbac_perms = {
+        'upload': 'terminal.add_virtualapp',
+    }
+
+
+class VirtualAppPublicationViewSet(viewsets.ModelViewSet):
+    queryset = VirtualAppPublication.objects.all()
+    serializer_class = serializers.VirtualAppPublicationSerializer
+    filterset_fields = ['app__name', 'provider__name', 'status']
+    search_fields = ['app__name', 'provider__name', ]
diff --git a/apps/terminal/connect_methods.py b/apps/terminal/connect_methods.py
index 9c7ee989a..27d67e1af 100644
--- a/apps/terminal/connect_methods.py
+++ b/apps/terminal/connect_methods.py
@@ -113,6 +113,26 @@ class AppletMethod:
         return methods
 
 
+class VirtualAppMethod:
+
+    @classmethod
+    def get_methods(cls):
+        from .models import VirtualApp
+        methods = defaultdict(list)
+        if not getattr(settings, 'VIRTUAL_APP_ENABLED'):
+            return methods
+        virtual_apps = VirtualApp.objects.filter(is_active=True)
+        for virtual_app in virtual_apps:
+            for protocol in virtual_app.protocols:
+                methods[protocol].append({
+                    'value': virtual_app.name,
+                    'label': virtual_app.name,
+                    'type': 'virtual_app',
+                    'disabled': not virtual_app.is_active,
+                })
+        return methods
+
+
 class ConnectMethodUtil:
     _all_methods = {}
 
@@ -243,6 +263,7 @@ class ConnectMethodUtil:
         methods = defaultdict(list)
         spec_web_methods = WebMethod.get_spec_methods()
         applet_methods = AppletMethod.get_methods()
+        virtual_app_methods = VirtualAppMethod.get_methods()
         native_methods = NativeClient.get_methods(os=os)
 
         for component, component_protocol in cls.components().items():
@@ -295,5 +316,12 @@ class ConnectMethodUtil:
                 method['component'] = TerminalType.tinker.value
             methods[asset_protocol].extend(applet_methods)
 
+        # 虚拟应用方式,这个只有 panda 提供,并且协议可能是自定义的
+        for protocol, virtual_app_methods in virtual_app_methods.items():
+            for method in virtual_app_methods:
+                method['listen'] = Protocol.http
+                method['component'] = TerminalType.panda.value
+            methods[protocol].extend(virtual_app_methods)
+
         cls._all_methods[os] = methods
         return methods
diff --git a/apps/terminal/const.py b/apps/terminal/const.py
index a545a0a3a..1c07f36b7 100644
--- a/apps/terminal/const.py
+++ b/apps/terminal/const.py
@@ -66,6 +66,7 @@ class TerminalType(TextChoices):
     video_worker = 'video_worker', 'Video Worker'
     chen = 'chen', 'Chen'
     kael = 'kael', 'Kael'
+    panda = 'panda', 'Panda'
 
     @classmethod
     def types(cls):
diff --git a/apps/terminal/migrations/0068_virtualapp.py b/apps/terminal/migrations/0068_virtualapp.py
new file mode 100644
index 000000000..957514480
--- /dev/null
+++ b/apps/terminal/migrations/0068_virtualapp.py
@@ -0,0 +1,93 @@
+# Generated by Django 4.1.10 on 2023-12-05 07:02
+
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('terminal', '0067_alter_replaystorage_type'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AppProvider',
+            fields=[
+                ('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')),
+                ('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')),
+                ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
+                ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
+                ('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
+                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=128, unique=True, verbose_name='Name')),
+                ('hostname', models.CharField(max_length=128, verbose_name='Hostname')),
+            ],
+            options={
+                'ordering': ('-date_created',),
+            },
+        ),
+        migrations.CreateModel(
+            name='VirtualApp',
+            fields=[
+                ('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')),
+                ('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')),
+                ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
+                ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
+                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
+                ('name', models.SlugField(max_length=128, unique=True, verbose_name='Name')),
+                ('display_name', models.CharField(max_length=128, verbose_name='Display name')),
+                ('version', models.CharField(max_length=16, verbose_name='Version')),
+                ('author', models.CharField(max_length=128, verbose_name='Author')),
+                ('is_active', models.BooleanField(default=True, verbose_name='Is active')),
+                ('protocols', models.JSONField(default=list, verbose_name='Protocol')),
+                ('image_name', models.CharField(max_length=128, verbose_name='Image name')),
+                ('image_protocol', models.CharField(default='vnc', max_length=16, verbose_name='Image protocol')),
+                ('image_port', models.IntegerField(default=5900, verbose_name='Image port')),
+                ('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
+                ('tags', models.JSONField(default=list, verbose_name='Tags')),
+            ],
+            options={
+                'verbose_name': 'Virtual app',
+            },
+        ),
+        migrations.AlterField(
+            model_name='terminal',
+            name='type',
+            field=models.CharField(choices=[('koko', 'KoKo'), ('guacamole', 'Guacamole'), ('omnidb', 'OmniDB'), ('xrdp', 'Xrdp'), ('lion', 'Lion'), ('core', 'Core'), ('celery', 'Celery'), ('magnus', 'Magnus'), ('razor', 'Razor'), ('tinker', 'Tinker'), ('video_worker', 'Video Worker'), ('chen', 'Chen'), ('kael', 'Kael'), ('panda', 'Panda')], default='koko', max_length=64, verbose_name='type'),
+        ),
+        migrations.CreateModel(
+            name='VirtualAppPublication',
+            fields=[
+                ('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')),
+                ('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')),
+                ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
+                ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
+                ('comment', models.TextField(blank=True, default='', verbose_name='Comment')),
+                ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
+                ('status', models.CharField(default='pending', max_length=16, verbose_name='Status')),
+                ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='publications', to='terminal.virtualapp', verbose_name='Virtual App')),
+                ('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='publications', to='terminal.appprovider', verbose_name='App Provider')),
+            ],
+            options={
+                'verbose_name': 'Virtual app publication',
+                'unique_together': {('provider', 'app')},
+            },
+        ),
+        migrations.AddField(
+            model_name='virtualapp',
+            name='providers',
+            field=models.ManyToManyField(through='terminal.VirtualAppPublication', to='terminal.appprovider', verbose_name='Providers'),
+        ),
+        migrations.AddField(
+            model_name='appprovider',
+            name='apps',
+            field=models.ManyToManyField(through='terminal.VirtualAppPublication', to='terminal.virtualapp', verbose_name='VirtualApp'),
+        ),
+        migrations.AddField(
+            model_name='appprovider',
+            name='terminal',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='app_provider', to='terminal.terminal', verbose_name='Terminal'),
+        ),
+    ]
diff --git a/apps/terminal/models/__init__.py b/apps/terminal/models/__init__.py
index 268727394..56fbd8cd9 100644
--- a/apps/terminal/models/__init__.py
+++ b/apps/terminal/models/__init__.py
@@ -1,3 +1,4 @@
 from .session import *
 from .component import *
 from .applet import *
+from .virtualapp import *
diff --git a/apps/terminal/models/virtualapp/__init__.py b/apps/terminal/models/virtualapp/__init__.py
new file mode 100644
index 000000000..f784f5c73
--- /dev/null
+++ b/apps/terminal/models/virtualapp/__init__.py
@@ -0,0 +1,2 @@
+from .provider import *
+from .virtualapp import *
diff --git a/apps/terminal/models/virtualapp/provider.py b/apps/terminal/models/virtualapp/provider.py
new file mode 100644
index 000000000..90a8df2ef
--- /dev/null
+++ b/apps/terminal/models/virtualapp/provider.py
@@ -0,0 +1,28 @@
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from common.db.models import JMSBaseModel
+
+__all__ = ['AppProvider', ]
+
+
+class AppProvider(JMSBaseModel):
+    name = models.CharField(max_length=128, verbose_name=_('Name'), unique=True)
+    hostname = models.CharField(max_length=128, verbose_name=_('Hostname'))
+    terminal = models.OneToOneField(
+        'terminal.Terminal', on_delete=models.CASCADE, null=True, blank=True,
+        related_name='app_provider', verbose_name=_('Terminal')
+    )
+    apps = models.ManyToManyField(
+        'VirtualApp', verbose_name=_('VirtualApp'),
+        through='VirtualAppPublication', through_fields=('provider', 'app'),
+    )
+
+    class Meta:
+        ordering = ('-date_created',)
+
+    @property
+    def load(self):
+        if not self.terminal:
+            return 'offline'
+        return self.terminal.load
diff --git a/apps/terminal/models/virtualapp/virtualapp.py b/apps/terminal/models/virtualapp/virtualapp.py
new file mode 100644
index 000000000..6035c76c4
--- /dev/null
+++ b/apps/terminal/models/virtualapp/virtualapp.py
@@ -0,0 +1,103 @@
+import os
+import shutil
+
+from django.conf import settings
+from django.core.files.storage import default_storage
+from django.db import models
+from django.utils._os import safe_join
+from django.utils.translation import gettext_lazy as _
+from rest_framework.serializers import ValidationError
+
+from common.db.models import JMSBaseModel
+from common.utils import lazyproperty
+from common.utils.yml import yaml_load_with_i18n
+
+__all__ = ['VirtualApp', 'VirtualAppPublication']
+
+
+class VirtualApp(JMSBaseModel):
+    name = models.SlugField(max_length=128, verbose_name=_('Name'), unique=True)
+    display_name = models.CharField(max_length=128, verbose_name=_('Display name'))
+    version = models.CharField(max_length=16, verbose_name=_('Version'))
+    author = models.CharField(max_length=128, verbose_name=_('Author'))
+    is_active = models.BooleanField(default=True, verbose_name=_('Is active'))
+    protocols = models.JSONField(default=list, verbose_name=_('Protocol'))
+    image_name = models.CharField(max_length=128, verbose_name=_('Image name'))
+    image_protocol = models.CharField(max_length=16, default='vnc', verbose_name=_('Image protocol'))
+    image_port = models.IntegerField(default=5900, verbose_name=_('Image port'))
+    comment = models.TextField(default='', blank=True, verbose_name=_('Comment'))
+    tags = models.JSONField(default=list, verbose_name=_('Tags'))
+    providers = models.ManyToManyField(
+        through_fields=('app', 'provider',), through='VirtualAppPublication',
+        to='AppProvider', verbose_name=_('Providers')
+    )
+
+    class Meta:
+        verbose_name = _('Virtual app')
+
+    def __str__(self):
+        return self.name
+
+    @property
+    def path(self):
+        return default_storage.path('virtual_apps/{}'.format(self.name))
+
+    @lazyproperty
+    def readme(self):
+        readme_file = os.path.join(self.path, 'README.md')
+        if os.path.isfile(readme_file):
+            with open(readme_file, 'r') as f:
+                return f.read()
+        return ''
+
+    @property
+    def icon(self):
+        path = os.path.join(self.path, 'icon.png')
+        if not os.path.exists(path):
+            return None
+        return os.path.join(settings.MEDIA_URL, 'virtual_apps', self.name, 'icon.png')
+
+    @staticmethod
+    def validate_pkg(d):
+        files = ['manifest.yml', 'icon.png', ]
+        for name in files:
+            path = safe_join(d, name)
+            if not os.path.exists(path):
+                raise ValidationError({'error': _('Applet pkg not valid, Missing file {}').format(name)})
+
+        with open(safe_join(d, 'manifest.yml'), encoding='utf8') as f:
+            manifest = yaml_load_with_i18n(f)
+
+        if not manifest.get('name', ''):
+            raise ValidationError({'error': 'Missing name in manifest.yml'})
+        return manifest
+
+    @classmethod
+    def install_from_dir(cls, path):
+        from terminal.serializers import VirtualAppSerializer
+        manifest = cls.validate_pkg(path)
+        name = manifest['name']
+        instance = cls.objects.filter(name=name).first()
+        serializer = VirtualAppSerializer(instance=instance, data=manifest)
+        serializer.is_valid(raise_exception=True)
+        instance = serializer.save()
+
+        pkg_path = default_storage.path('virtual_apps/{}'.format(name))
+        if os.path.exists(pkg_path):
+            shutil.rmtree(pkg_path)
+        shutil.copytree(path, pkg_path)
+        return instance, serializer
+
+
+class VirtualAppPublication(JMSBaseModel):
+    provider = models.ForeignKey(
+        'AppProvider', on_delete=models.CASCADE, related_name='publications', verbose_name=_('App Provider')
+    )
+    app = models.ForeignKey(
+        'VirtualApp', on_delete=models.CASCADE, related_name='publications', verbose_name=_('Virtual App')
+    )
+    status = models.CharField(max_length=16, default='pending', verbose_name=_('Status'))
+
+    class Meta:
+        verbose_name = _('Virtual app publication')
+        unique_together = ('provider', 'app')
diff --git a/apps/terminal/serializers/__init__.py b/apps/terminal/serializers/__init__.py
index dc23362f9..ba97ae16d 100644
--- a/apps/terminal/serializers/__init__.py
+++ b/apps/terminal/serializers/__init__.py
@@ -9,3 +9,5 @@ from .sharing import *
 from .storage import *
 from .task import *
 from .terminal import *
+from .virtualapp import *
+from .virtualapp_provider import *
diff --git a/apps/terminal/serializers/virtualapp.py b/apps/terminal/serializers/virtualapp.py
new file mode 100644
index 000000000..f55226eaf
--- /dev/null
+++ b/apps/terminal/serializers/virtualapp.py
@@ -0,0 +1,41 @@
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from common.const.choices import Status
+from common.serializers.fields import ObjectRelatedField, LabeledChoiceField
+from terminal.const import PublishStatus
+from ..models import VirtualApp, VirtualAppPublication, AppProvider
+
+__all__ = [
+    'VirtualAppSerializer', 'VirtualAppPublicationSerializer'
+]
+
+
+class VirtualAppSerializer(serializers.ModelSerializer):
+    icon = serializers.ReadOnlyField(label=_("Icon"))
+    image_protocol = serializers.CharField(max_length=16, default='vnc')
+    image_port = serializers.IntegerField(default=5900)
+
+    class Meta:
+        model = VirtualApp
+        fields_mini = ['id', 'display_name', 'name', 'image_name', 'is_active']
+        read_only_fields = [
+            'icon', 'readme', 'date_created', 'date_updated',
+        ]
+        fields = fields_mini + [
+            'version', 'author', 'image_protocol', 'image_port',
+            'protocols', 'tags', 'comment',
+        ] + read_only_fields
+
+
+class VirtualAppPublicationSerializer(serializers.ModelSerializer):
+    app = ObjectRelatedField(attrs=('id', 'name', 'image_name',), label=_("Virtual App"),
+                             queryset=VirtualApp.objects.all())
+    provider = ObjectRelatedField(queryset=AppProvider.objects.all(), label=_("App Provider"))
+    status = LabeledChoiceField(choices=PublishStatus.choices, label=_("Status"), default=Status.pending)
+
+    class Meta:
+        model = VirtualAppPublication
+        fields_mini = ['id', 'provider', 'app']
+        read_only_fields = ['date_created', 'date_updated']
+        fields = fields_mini + ['status', 'comment'] + read_only_fields
diff --git a/apps/terminal/serializers/virtualapp_provider.py b/apps/terminal/serializers/virtualapp_provider.py
new file mode 100644
index 000000000..bfce5e081
--- /dev/null
+++ b/apps/terminal/serializers/virtualapp_provider.py
@@ -0,0 +1,31 @@
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from common.serializers.fields import LabeledChoiceField
+from terminal import const
+from ..models import AppProvider
+
+__all__ = ['AppProviderSerializer', 'AppProviderContainerSerializer', ]
+
+
+class AppProviderSerializer(serializers.ModelSerializer):
+    load = LabeledChoiceField(
+        read_only=True, label=_('Load status'), choices=const.ComponentLoad.choices,
+    )
+
+    class Meta:
+        model = AppProvider
+        field_mini = ['id', 'name', 'hostname']
+        read_only_fields = [
+            'date_created', 'date_updated',
+        ]
+        fields = field_mini + ['load', 'terminal'] + read_only_fields
+
+
+class AppProviderContainerSerializer(serializers.Serializer):
+    container_id = serializers.CharField(label=_('Container ID'))
+    container_image = serializers.CharField(label=_('Container Image'))
+    container_name = serializers.CharField(label=_('Container Name'))
+    container_status = serializers.CharField(label=_('Container Status'))
+    container_ports = serializers.ListField(child=serializers.CharField(), label=_('Container Ports'))
+
diff --git a/apps/terminal/signal_handlers/__init__.py b/apps/terminal/signal_handlers/__init__.py
index bd61c885c..08a6ac407 100644
--- a/apps/terminal/signal_handlers/__init__.py
+++ b/apps/terminal/signal_handlers/__init__.py
@@ -3,3 +3,4 @@ from .db_port import *
 from .session import *
 from .session_sharing import *
 from .terminal import *
+from .virtualapp import *
diff --git a/apps/terminal/signal_handlers/virtualapp.py b/apps/terminal/signal_handlers/virtualapp.py
new file mode 100644
index 000000000..37c4ed1a1
--- /dev/null
+++ b/apps/terminal/signal_handlers/virtualapp.py
@@ -0,0 +1,24 @@
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from common.decorators import on_transaction_commit
+from ..models import AppProvider, VirtualApp
+
+
+@receiver(post_save, sender=AppProvider)
+@on_transaction_commit
+def on_virtual_host_create(sender, instance, created=False, **kwargs):
+    if not created:
+        return
+    apps = VirtualApp.objects.all()
+    instance.apps.set(apps)
+
+
+@receiver(post_save, sender=VirtualApp)
+def on_virtual_app_create(sender, instance, created=False, **kwargs):
+    if not created:
+        return
+    providers = AppProvider.objects.all()
+    if len(providers) == 0:
+        return
+    instance.providers.set(providers)
diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py
index 3c38bb934..258e2f0d1 100644
--- a/apps/terminal/urls/api_urls.py
+++ b/apps/terminal/urls/api_urls.py
@@ -30,6 +30,10 @@ router.register(r'applet-hosts', api.AppletHostViewSet, 'applet-host')
 router.register(r'applet-publications', api.AppletPublicationViewSet, 'applet-publication')
 router.register(r'applet-host-deployments', api.AppletHostDeploymentViewSet, 'applet-host-deployment')
 router.register(r'db-listen-ports', api.DBListenPortViewSet, 'db-listen-ports')
+router.register(r'virtual-apps', api.VirtualAppViewSet, 'virtual-app')
+router.register(r'app-providers', api.AppProviderViewSet, 'app-provider')
+router.register(r'app-providers/((?P<provider>[^/.]+)/)?apps', api.AppProviderAppViewSet, 'app-provider-app')
+router.register(r'virtual-app-publications', api.VirtualAppPublicationViewSet, 'virtual-app-publication')
 
 urlpatterns = [
     path('my-sessions/', api.MySessionAPIView.as_view(), name='my-session'),