diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 3b19bb16e..0ce5e9da3 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -35,6 +35,7 @@ def parse_sentinels_host(sentinels_host): VERSION = const.VERSION BASE_DIR = const.BASE_DIR PROJECT_DIR = const.PROJECT_DIR +APP_DIR = os.path.join(PROJECT_DIR, 'apps') DATA_DIR = os.path.join(PROJECT_DIR, 'data') ANSIBLE_DIR = os.path.join(DATA_DIR, 'ansible') CERTS_DIR = os.path.join(DATA_DIR, 'certs') diff --git a/apps/ops/migrations/0030_jobauditlog.py b/apps/ops/migrations/0030_jobauditlog.py new file mode 100644 index 000000000..ff933e447 --- /dev/null +++ b/apps/ops/migrations/0030_jobauditlog.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.14 on 2022-12-20 07:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ops', '0029_auto_20221215_1712'), + ] + + operations = [ + migrations.CreateModel( + name='JobAuditLog', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('ops.jobexecution',), + ), + ] diff --git a/apps/rbac/migrations/0001_initial.py b/apps/rbac/migrations/0001_initial.py index 8be92f6d3..624b88dd3 100644 --- a/apps/rbac/migrations/0001_initial.py +++ b/apps/rbac/migrations/0001_initial.py @@ -1,16 +1,17 @@ # Generated by Django 3.1.13 on 2021-11-19 08:29 -import common.db.models -from django.conf import settings +import uuid + import django.contrib.auth.models import django.contrib.contenttypes.models -from django.db import migrations, models import django.db.models.deletion -import uuid +from django.conf import settings +from django.db import migrations, models + +import common.db.models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -28,7 +29,8 @@ class Migration(migrations.Migration): ], options={ 'verbose_name': 'Menu permission', - 'permissions': [('view_console', 'Can view console view'), ('view_audit', 'Can view audit view'), ('view_workspace', 'Can view workbench view')], + 'permissions': [('view_console', 'Can view console view'), ('view_audit', 'Can view audit view'), + ('view_workspace', 'Can view workbench view')], 'default_permissions': [], }, ), @@ -41,8 +43,9 @@ class Migration(migrations.Migration): ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ('name', models.CharField(max_length=128, verbose_name='Name')), - ('scope', models.CharField(choices=[('system', 'System'), ('org', 'Organization')], default='system', max_length=128, verbose_name='Scope')), - ('builtin', models.BooleanField(default=False, verbose_name='Built-in')), + ('scope', models.CharField(choices=[('system', 'System'), ('org', 'Organization')], default='system', + max_length=128, verbose_name='Scope')), + ('builtin', models.BooleanField(default=False, verbose_name='Builtin')), ('comment', models.TextField(blank=True, default='', max_length=128, verbose_name='Comment')), ], ), @@ -82,10 +85,15 @@ class Migration(migrations.Migration): ('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)), - ('scope', models.CharField(choices=[('system', 'System'), ('org', 'Organization')], default='system', max_length=128, verbose_name='Scope')), - ('org', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='role_bindings', to='orgs.organization', verbose_name='Organization')), - ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_bindings', to='rbac.role', verbose_name='Role')), - ('user', models.ForeignKey(on_delete=common.db.models.CASCADE_SIGNAL_SKIP, related_name='role_bindings', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ('scope', models.CharField(choices=[('system', 'System'), ('org', 'Organization')], default='system', + max_length=128, verbose_name='Scope')), + ('org', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + related_name='role_bindings', to='orgs.organization', + verbose_name='Organization')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='role_bindings', + to='rbac.role', verbose_name='Role')), + ('user', models.ForeignKey(on_delete=common.db.models.CASCADE_SIGNAL_SKIP, related_name='role_bindings', + to=settings.AUTH_USER_MODEL, verbose_name='User')), ], options={ 'verbose_name': 'Role binding', @@ -95,7 +103,8 @@ class Migration(migrations.Migration): migrations.AddField( model_name='role', name='permissions', - field=models.ManyToManyField(blank=True, related_name='roles', to='rbac.Permission', verbose_name='Permissions'), + field=models.ManyToManyField(blank=True, related_name='roles', to='rbac.Permission', + verbose_name='Permissions'), ), migrations.AlterUniqueTogether( name='role', diff --git a/apps/rbac/models/role.py b/apps/rbac/models/role.py index 05783f598..1eff7c15c 100644 --- a/apps/rbac/models/role.py +++ b/apps/rbac/models/role.py @@ -1,11 +1,11 @@ -from django.utils.translation import ugettext_lazy as _, gettext from django.db import models +from django.utils.translation import ugettext_lazy as _, gettext from common.db.models import JMSBaseModel from common.utils import lazyproperty from .permission import Permission -from ..builtin import BuiltinRole from .. import const +from ..builtin import BuiltinRole __all__ = ['Role', 'SystemRole', 'OrgRole'] @@ -33,7 +33,7 @@ class Role(JMSBaseModel): permissions = models.ManyToManyField( 'rbac.Permission', related_name='roles', blank=True, verbose_name=_('Permissions') ) - builtin = models.BooleanField(default=False, verbose_name=_('Built-in')) + builtin = models.BooleanField(default=False, verbose_name=_('Builtin')) comment = models.TextField(max_length=128, default='', blank=True, verbose_name=_('Comment')) BuiltinRole = BuiltinRole @@ -71,14 +71,14 @@ class Role(JMSBaseModel): @classmethod def get_roles_permissions(cls, roles): org_roles = [role for role in roles if role.scope == cls.Scope.org] - org_perms_id = cls.get_scope_roles_perms(org_roles, cls.Scope.org)\ + org_perms_id = cls.get_scope_roles_perms(org_roles, cls.Scope.org) \ .values_list('id', flat=True) system_roles = [role for role in roles if role.scope == cls.Scope.system] - system_perms_id = cls.get_scope_roles_perms(system_roles, cls.Scope.system)\ + system_perms_id = cls.get_scope_roles_perms(system_roles, cls.Scope.system) \ .values_list('id', flat=True) perms_id = set(org_perms_id) | set(system_perms_id) - permissions = Permission.objects.filter(id__in=perms_id)\ + permissions = Permission.objects.filter(id__in=perms_id) \ .prefetch_related('content_type') return permissions diff --git a/apps/terminal/api/applet/applet.py b/apps/terminal/api/applet/applet.py index a00814dd3..b30b33f36 100644 --- a/apps/terminal/api/applet/applet.py +++ b/apps/terminal/api/applet/applet.py @@ -1,23 +1,22 @@ +import os.path import shutil import zipfile -import yaml -import os.path from typing import Callable -from django.http import HttpResponse +from django.conf import settings from django.core.files.storage import default_storage +from django.http import HttpResponse 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.utils import is_uuid from common.drf.serializers import FileSerializer +from common.utils import is_uuid from terminal import serializers from terminal.models import AppletPublication, Applet - __all__ = ['AppletViewSet', 'AppletPublicationViewSet'] @@ -46,17 +45,7 @@ class DownloadUploadMixin: zp.extractall(extract_to) tmp_dir = os.path.join(extract_to, file.name.replace('.zip', '')) - files = ['manifest.yml', 'icon.png', 'i18n.yml', 'setup.yml'] - for name in files: - path = os.path.join(tmp_dir, name) - if not os.path.exists(path): - raise ValidationError({'error': 'Missing file {}'.format(name)}) - - with open(os.path.join(tmp_dir, 'manifest.yml')) as f: - manifest = yaml.safe_load(f) - - if not manifest.get('name', ''): - raise ValidationError({'error': 'Missing name in manifest.yml'}) + manifest = Applet.validate_pkg(tmp_dir) return manifest, tmp_dir @action(detail=False, methods=['post'], serializer_class=FileSerializer) @@ -81,7 +70,10 @@ class DownloadUploadMixin: @action(detail=True, methods=['get']) def download(self, request, *args, **kwargs): instance = self.get_object() - path = default_storage.path('applets/{}'.format(instance.name)) + if instance.builtin: + path = os.path.join(settings.APPS_DIR, 'terminal', 'applets', instance.name) + else: + path = default_storage.path('applets/{}'.format(instance.name)) zip_path = shutil.make_archive(path, 'zip', path) with open(zip_path, 'rb') as f: response = HttpResponse(f.read(), status=200, content_type='application/octet-stream') diff --git a/apps/terminal/applets/__init__.py b/apps/terminal/applets/__init__.py new file mode 100644 index 000000000..bb09feecd --- /dev/null +++ b/apps/terminal/applets/__init__.py @@ -0,0 +1,22 @@ +import os + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def install_or_update_builtin_applets(): + from terminal.models import Applet + + applets = os.listdir(BASE_DIR) + for d in applets: + path = os.path.join(BASE_DIR, d) + if not os.path.isdir(path) or not os.path.exists(os.path.join(path, 'manifest.yml')): + continue + print("Install or update applet: {}".format(path)) + try: + Applet.install_from_dir(path) + except Exception as e: + print(e) + + +if __name__ == '__main__': + install_or_update_builtin_applets() diff --git a/apps/terminal/applets/chrome/README.md b/apps/terminal/applets/chrome/README.md new file mode 100644 index 000000000..068682bfb --- /dev/null +++ b/apps/terminal/applets/chrome/README.md @@ -0,0 +1,7 @@ + +## selenium 版本 + +- Selenium == 4.4.0 +- Chrome 和 ChromeDriver 版本要匹配 +- Driver [下载地址](https://chromedriver.chromium.org/downloads) + diff --git a/apps/terminal/applets/chrome/app.py b/apps/terminal/applets/chrome/app.py new file mode 100644 index 000000000..78e3e2c5a --- /dev/null +++ b/apps/terminal/applets/chrome/app.py @@ -0,0 +1,201 @@ +import time +from enum import Enum +from subprocess import CREATE_NO_WINDOW + +from selenium import webdriver +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.common.by import By +from selenium.webdriver.chrome.service import Service + +from common import (Asset, User, Account, Platform) +from common import (notify_err_message, block_input, unblock_input) +from common import (BaseApplication) + + +class Command(Enum): + TYPE = 'type' + CLICK = 'click' + OPEN = 'open' + + +def _execute_type(ele: WebElement, value: str): + ele.send_keys(value) + + +def _execute_click(ele: WebElement, value: str): + ele.click() + + +commands_func_maps = { + Command.CLICK: _execute_click, + Command.TYPE: _execute_type, + Command.OPEN: _execute_type, +} + + +class StepAction: + methods_map = { + "NAME": By.NAME, + "ID": By.ID, + "CLASS_NAME": By.CLASS_NAME, + "CSS_SELECTOR": By.CSS_SELECTOR, + "CSS": By.CSS_SELECTOR, + "XPATH": By.XPATH + } + + def __init__(self, target='', value='', command=Command.TYPE, **kwargs): + self.target = target + self.value = value + self.command = command + + def execute(self, driver: webdriver.Chrome) -> bool: + if not self.target: + return True + target_name, target_value = self.target.split("=", 1) + by_name = self.methods_map.get(target_name.upper(), By.NAME) + ele = driver.find_element(by=by_name, value=target_value) + if not ele: + return False + if self.command == 'type': + ele.send_keys(self.value) + elif self.command in ['click', 'button']: + ele.click() + elif self.command in ['open']: + driver.get(self.value) + return True + + def _execute_command_type(self, ele, value): + ele.send_keys(value) + + +def execute_action(driver: webdriver.Chrome, step: StepAction) -> bool: + try: + return step.execute(driver) + except Exception as e: + print(e) + notify_err_message(str(e)) + return False + + +class WebAPP(object): + + def __init__(self, app_name: str = '', user: User = None, asset: Asset = None, + account: Account = None, platform: Platform = None, **kwargs): + self.app_name = app_name + self.user = user + self.asset = asset + self.account = account + self.platform = platform + + self.extra_data = self.asset.specific + self._steps = list() + autofill_type = self.asset.specific.autofill + if autofill_type == "basic": + self._steps = self._default_custom_steps() + elif autofill_type == "script": + steps = sorted(self.asset.specific.script, key=lambda step_item: step_item['step']) + for item in steps: + val = item['value'] + if val: + val = val.replace("{USERNAME}", self.account.username) + val = val.replace("{SECRET}", self.account.secret) + item['value'] = val + self._steps.append(item) + + def _default_custom_steps(self) -> list: + account = self.account + specific_property = self.asset.specific + default_steps = [ + { + "step": 1, + "value": account.username, + "target": specific_property.username_selector, + "command": "type" + }, + { + "step": 2, + "value": account.secret, + "target": specific_property.password_selector, + "command": "type" + }, + { + "step": 3, + "value": "", + "target": specific_property.submit_selector, + "command": "click" + } + ] + return default_steps + + def execute(self, driver: webdriver.Chrome) -> bool: + if not self.asset.address: + return True + + for step in self._steps: + action = StepAction(**step) + ret = execute_action(driver, action) + if not ret: + unblock_input() + notify_err_message(f"执行失败: target: {action.target} command: {action.command}") + block_input() + return False + return True + + +def default_chrome_driver_options(): + options = webdriver.ChromeOptions() + options.add_argument("start-maximized") + # 禁用 扩展 + options.add_argument("--disable-extensions") + # 禁用开发者工具 + options.add_argument("--disable-dev-tools") + # 禁用 密码管理器弹窗 + prefs = {"credentials_enable_service": False, + "profile.password_manager_enabled": False} + options.add_experimental_option("prefs", prefs) + options.add_experimental_option("excludeSwitches", ['enable-automation']) + return options + + +class AppletApplication(BaseApplication): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.driver = None + self.app = WebAPP(app_name=self.app_name, user=self.user, + account=self.account, asset=self.asset, platform=self.platform) + self._chrome_options = default_chrome_driver_options() + + def run(self): + service = Service() + # driver 的 console 终端框不显示 + service.creationflags = CREATE_NO_WINDOW + self.driver = webdriver.Chrome(options=self._chrome_options, service=service) + self.driver.implicitly_wait(10) + if self.app.asset.address != "": + self.driver.get(self.app.asset.address) + ok = self.app.execute(self.driver) + if not ok: + print("执行失败") + self.driver.maximize_window() + + def wait(self): + msg = "Unable to evaluate script: disconnected: not connected to DevTools\n" + while True: + time.sleep(5) + logs = self.driver.get_log('driver') + if len(logs) == 0: + continue + ret = logs[-1] + if isinstance(ret, dict): + if ret.get("message") == msg: + print(ret) + break + self.close() + + def close(self): + if self.driver: + try: + self.driver.close() + except Exception as e: + print(e) diff --git a/apps/terminal/applets/chrome/common.py b/apps/terminal/applets/chrome/common.py new file mode 100644 index 000000000..75e59a4c1 --- /dev/null +++ b/apps/terminal/applets/chrome/common.py @@ -0,0 +1,197 @@ +import abc +import subprocess +import sys +import time +import os +import json +import base64 +from subprocess import CREATE_NO_WINDOW + +_blockInput = None +_messageBox = None +if sys.platform == 'win32': + import ctypes + from ctypes import wintypes + import win32ui + + # import win32con + + _messageBox = win32ui.MessageBox + + _blockInput = ctypes.windll.user32.BlockInput + _blockInput.argtypes = [wintypes.BOOL] + _blockInput.restype = wintypes.BOOL + + +def block_input(): + if _blockInput: + _blockInput(True) + + +def unblock_input(): + if _blockInput: + _blockInput(False) + + +def notify_err_message(msg): + if _messageBox: + _messageBox(msg, 'Error') + + +def check_pid_alive(pid) -> bool: + # tasklist /fi "PID eq 508" /fo csv + # '"映像名称","PID","会话名 ","会话# ","内存使用 "\r\n"wininit.exe","508","Services","0","6,920 K"\r\n' + try: + + csv_ret = subprocess.check_output(["tasklist", "/fi", f'PID eq {pid}', "/fo", "csv"], + creationflags=CREATE_NO_WINDOW) + content = csv_ret.decode() + content_list = content.strip().split("\r\n") + if len(content_list) != 2: + notify_err_message(content) + return False + ret_pid = content_list[1].split(",")[1].strip('"') + return str(pid) == ret_pid + except Exception as e: + notify_err_message(e) + return False + + +def wait_pid(pid): + while 1: + time.sleep(5) + ok = check_pid_alive(pid) + if not ok: + notify_err_message("程序退出") + break + + +class DictObj: + def __init__(self, in_dict: dict): + assert isinstance(in_dict, dict) + for key, val in in_dict.items(): + if isinstance(val, (list, tuple)): + setattr(self, key, [DictObj(x) if isinstance(x, dict) else x for x in val]) + else: + setattr(self, key, DictObj(val) if isinstance(val, dict) else val) + + +class User(DictObj): + id: str + name: str + username: str + + +class Specific(DictObj): + # web + autofill: str + username_selector: str + password_selector: str + submit_selector: str + script: list + + # database + db_name: str + + +class Category(DictObj): + value: str + label: str + + +class Protocol(DictObj): + id: str + name: str + port: int + + +class Asset(DictObj): + id: str + name: str + address: str + protocols: list[Protocol] + category: Category + specific: Specific + + def get_protocol_port(self, protocol): + for item in self.protocols: + if item.name == protocol: + return item.port + return None + + +class LabelValue(DictObj): + label: str + value: str + + +class Account(DictObj): + id: str + name: str + username: str + secret: str + secret_type: LabelValue + + +class Platform(DictObj): + id: str + name: str + charset: LabelValue + type: LabelValue + + +class Manifest(DictObj): + name: str + version: str + path: str + exec_type: str + connect_type: str + protocols: list[str] + + +def get_manifest_data() -> dict: + current_dir = os.path.dirname(__file__) + manifest_file = os.path.join(current_dir, 'manifest.json') + try: + with open(manifest_file, "r", encoding='utf8') as f: + return json.load(f) + except Exception as e: + print(e) + return {} + + +def read_app_manifest(app_dir) -> dict: + main_json_file = os.path.join(app_dir, "manifest.json") + if not os.path.exists(main_json_file): + return {} + with open(main_json_file, 'r', encoding='utf8') as f: + return json.load(f) + + +def convert_base64_to_dict(base64_str: str) -> dict: + try: + data_json = base64.decodebytes(base64_str.encode('utf-8')).decode('utf-8') + return json.loads(data_json) + except Exception as e: + print(e) + return {} + + +class BaseApplication(abc.ABC): + + def __init__(self, *args, **kwargs): + self.app_name = kwargs.get('app_name', '') + self.protocol = kwargs.get('protocol', '') + self.manifest = Manifest(kwargs.get('manifest', {})) + self.user = User(kwargs.get('user', {})) + self.asset = Asset(kwargs.get('asset', {})) + self.account = Account(kwargs.get('account', {})) + self.platform = Platform(kwargs.get('platform', {})) + + @abc.abstractmethod + def run(self): + raise NotImplementedError('run') + + @abc.abstractmethod + def wait(self): + raise NotImplementedError('wait') diff --git a/apps/terminal/applets/chrome/i18n.yml b/apps/terminal/applets/chrome/i18n.yml new file mode 100644 index 000000000..d91977ba6 --- /dev/null +++ b/apps/terminal/applets/chrome/i18n.yml @@ -0,0 +1,4 @@ +- zh: + display_name: Chrome 浏览器 + comment: 浏览器打开 URL 页面地址 + diff --git a/apps/terminal/applets/chrome/icon.png b/apps/terminal/applets/chrome/icon.png new file mode 100644 index 000000000..ee35972d8 Binary files /dev/null and b/apps/terminal/applets/chrome/icon.png differ diff --git a/apps/terminal/applets/chrome/main.py b/apps/terminal/applets/chrome/main.py new file mode 100644 index 000000000..be0ff3585 --- /dev/null +++ b/apps/terminal/applets/chrome/main.py @@ -0,0 +1,22 @@ +import sys + +from common import (block_input, unblock_input) +from common import convert_base64_to_dict +from app import AppletApplication + + +def main(): + base64_str = sys.argv[1] + data = convert_base64_to_dict(base64_str) + applet_app = AppletApplication(**data) + block_input() + applet_app.run() + unblock_input() + applet_app.wait() + + +if __name__ == '__main__': + try: + main() + except Exception as e: + print(e) diff --git a/apps/terminal/applets/chrome/manifest.yml b/apps/terminal/applets/chrome/manifest.yml new file mode 100644 index 000000000..f2681a0c2 --- /dev/null +++ b/apps/terminal/applets/chrome/manifest.yml @@ -0,0 +1,12 @@ +name: chrome +display_name: Chrome Browser +version: 0.1 +comment: Chrome Browser Open URL Page Address +author: JumpServer Team +exec_type: python +update_policy: always +type: web +tags: + - web +protocols: + - http diff --git a/apps/terminal/applets/chrome/setup.yml b/apps/terminal/applets/chrome/setup.yml new file mode 100644 index 000000000..9b9a950f1 --- /dev/null +++ b/apps/terminal/applets/chrome/setup.yml @@ -0,0 +1,6 @@ +type: manual # exe, zip, manual +source: +arguments: +destination: +program: +md5: diff --git a/apps/terminal/applets/chrome/test_data_example.json b/apps/terminal/applets/chrome/test_data_example.json new file mode 100644 index 000000000..fc8e00991 --- /dev/null +++ b/apps/terminal/applets/chrome/test_data_example.json @@ -0,0 +1,41 @@ +{ + "protocol": "web", + "user": { + "id": "2647CA35-5CAD-4DDF-8A88-6BD88F39BB30", + "name": "Administrator", + "username": "admin" + }, + "asset": { + "id": "46EE5F50-F1C1-468C-97EE-560E3436754C", + "name": "test_baidu", + "address": "https://www.baidu.com", + "category": { + "value": "web", + "label": "web" + }, + "protocols": [ + { + "id": 2, + "name": "http", + "port": 80 + } + ], + "specific": { + "autofill": "basic", + "username_selector": "name=username", + "password_selector": "name=password", + "submit_selector": "id=longin_button", + "script": [] + }, + "org_id": "2925D985-A435-411D-9BC4-FEA630F105D9" + }, + "account": { + "id": "9D5585DE-5132-458C-AABE-89A83C112A83", + "name": "test_mysql", + "username": "root", + "secret": "" + }, + "platform": { + "charset": "UTF-8" + } +} diff --git a/apps/terminal/management/__init__.py b/apps/terminal/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/terminal/management/commands/__init__.py b/apps/terminal/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/terminal/management/commands/install_builtin_applets.py b/apps/terminal/management/commands/install_builtin_applets.py new file mode 100644 index 000000000..d3909d16b --- /dev/null +++ b/apps/terminal/management/commands/install_builtin_applets.py @@ -0,0 +1,9 @@ +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'Install builtin applets' + + def handle(self, *args, **options): + from terminal.applets import install_or_update_builtin_applets + install_or_update_builtin_applets() diff --git a/apps/terminal/migrations/0063_applet_builtin.py b/apps/terminal/migrations/0063_applet_builtin.py new file mode 100644 index 000000000..1d991e180 --- /dev/null +++ b/apps/terminal/migrations/0063_applet_builtin.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2022-12-20 07:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0062_auto_20221216_1529'), + ] + + operations = [ + migrations.AddField( + model_name='applet', + name='builtin', + field=models.BooleanField(default=False, verbose_name='Builtin'), + ), + ] diff --git a/apps/terminal/models/applet/applet.py b/apps/terminal/models/applet/applet.py index bfe0e5e67..9c712b6e5 100644 --- a/apps/terminal/models/applet/applet.py +++ b/apps/terminal/models/applet/applet.py @@ -7,6 +7,7 @@ from django.core.cache import cache from django.core.files.storage import default_storage from django.db import models from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import ValidationError from common.db.models import JMSBaseModel @@ -24,6 +25,7 @@ class Applet(JMSBaseModel): author = models.CharField(max_length=128, verbose_name=_('Author')) type = models.CharField(max_length=16, verbose_name=_('Type'), default='general', choices=Type.choices) is_active = models.BooleanField(default=True, verbose_name=_('Is active')) + builtin = models.BooleanField(default=False, verbose_name=_('Builtin')) protocols = models.JSONField(default=list, verbose_name=_('Protocol')) tags = models.JSONField(default=list, verbose_name=_('Tags')) comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) @@ -37,7 +39,10 @@ class Applet(JMSBaseModel): @property def path(self): - return default_storage.path('applets/{}'.format(self.name)) + if self.builtin: + return os.path.join(settings.APPS_DIR, 'terminal', 'applets', self.name) + else: + return default_storage.path('applets/{}'.format(self.name)) @property def manifest(self): @@ -54,6 +59,33 @@ class Applet(JMSBaseModel): return None return os.path.join(settings.MEDIA_URL, 'applets', self.name, 'icon.png') + @staticmethod + def validate_pkg(d): + files = ['manifest.yml', 'icon.png', 'i18n.yml', 'setup.yml'] + for name in files: + path = os.path.join(d, name) + if not os.path.exists(path): + raise ValidationError({'error': 'Missing file {}'.format(path)}) + + with open(os.path.join(d, 'manifest.yml')) as f: + manifest = yaml.safe_load(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 AppletSerializer + + manifest = cls.validate_pkg(path) + name = manifest['name'] + instance = cls.objects.filter(name=name).first() + serializer = AppletSerializer(instance=instance, data=manifest) + serializer.is_valid() + serializer.save(builtin=True) + return instance + def select_host_account(self): hosts = list(self.hosts.all()) if not hosts: @@ -73,6 +105,7 @@ class Applet(JMSBaseModel): ttl = 60 * 60 * 24 lock_key = 'applet_host_accounts_{}_{}'.format(host.id, account.username) cache.set(lock_key, account.username, ttl) + return { 'host': host, 'account': account, diff --git a/jms b/jms index 2bb8f64c4..91021767e 100755 --- a/jms +++ b/jms @@ -1,14 +1,14 @@ #!/usr/bin/env python3 # coding: utf-8 -import os +import argparse import logging import logging.handlers -import time -import argparse +import os import sys +import time + import django -import requests from django.core import management from django.db.utils import OperationalError @@ -24,6 +24,7 @@ logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(message)s", datef try: from jumpserver import const + __version__ = const.VERSION except ImportError as e: print("Not found __version__: {}".format(e)) @@ -122,6 +123,14 @@ def download_ip_db(): download_file(src, path) +def install_builtin_applets(): + logging.info("Install builtin applets") + try: + management.call_command('install_builtin_applets', verbosity=0, interactive=False) + except: + pass + + def upgrade_db(): collect_static() perform_db_migrate() @@ -132,6 +141,7 @@ def prepare(): upgrade_db() expire_caches() download_ip_db() + install_builtin_applets() def start_services(): @@ -190,4 +200,3 @@ if __name__ == '__main__': collect_static() else: start_services() -