diff --git a/Dockerfile b/Dockerfile index b030c25b0..5ed72c554 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ -FROM registry.fit2cloud.com/public/python:v3 as stage-build -MAINTAINER Jumpserver Team <ibuler@qq.com> +# 编译代码 +FROM python:3.8.6-slim as stage-build +MAINTAINER JumpServer Team <ibuler@qq.com> ARG VERSION ENV VERSION=$VERSION @@ -8,33 +9,38 @@ ADD . . RUN cd utils && bash -ixeu build.sh -FROM registry.fit2cloud.com/public/python:v3 +# 构建运行时环境 +FROM python:3.8.6-slim ARG PIP_MIRROR=https://pypi.douban.com/simple ENV PIP_MIRROR=$PIP_MIRROR -ARG MYSQL_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/mysql/yum/mysql57-community-el6/ -ENV MYSQL_MIRROR=$MYSQL_MIRROR +ARG PIP_JMS_MIRROR=https://pypi.douban.com/simple +ENV PIP_JMS_MIRROR=$PIP_JMS_MIRROR WORKDIR /opt/jumpserver -COPY ./requirements ./requirements -RUN useradd jumpserver -RUN yum -y install epel-release && \ - echo -e "[mysql]\nname=mysql\nbaseurl=${MYSQL_MIRROR}\ngpgcheck=0\nenabled=1" > /etc/yum.repos.d/mysql.repo -RUN yum -y install $(cat requirements/rpm_requirements.txt) -RUN pip install --upgrade pip setuptools==49.6.0 wheel -i ${PIP_MIRROR} && \ - pip config set global.index-url ${PIP_MIRROR} -RUN pip install $(grep 'jms' requirements/requirements.txt) -i https://pypi.org/simple -RUN pip install -r requirements/requirements.txt +COPY ./requirements/deb_buster_requirements.txt ./requirements/deb_buster_requirements.txt +RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ + && sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ + && apt update \ + && grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \ + && localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \ + && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime + +COPY ./requirements/requirements.txt ./requirements/requirements.txt +RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \ + && pip config set global.index-url ${PIP_MIRROR} \ + && pip install --no-cache-dir $(grep 'jms' requirements/requirements.txt) -i ${PIP_JMS_MIRROR} \ + && pip install --no-cache-dir -r requirements/requirements.txt COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver -RUN mkdir -p /root/.ssh/ && echo -e "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config +RUN mkdir -p /root/.ssh/ \ + && echo -e "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config RUN echo > config.yml VOLUME /opt/jumpserver/data VOLUME /opt/jumpserver/logs ENV LANG=zh_CN.UTF-8 -ENV LC_ALL=zh_CN.UTF-8 EXPOSE 8070 EXPOSE 8080 diff --git a/apps/applications/api/mixin.py b/apps/applications/api/mixin.py index fe51e1dc7..91c7d7b3f 100644 --- a/apps/applications/api/mixin.py +++ b/apps/applications/api/mixin.py @@ -1,4 +1,5 @@ from common.exceptions import JMSException +from orgs.models import Organization from .. import models @@ -85,11 +86,46 @@ class SerializeApplicationToTreeNodeMixin: 'meta': {'type': 'k8s_app'} } - def _serialize(self, application): + def _serialize_application(self, application): method_name = f'_serialize_{application.category}' data = getattr(self, method_name)(application) + data.update({ + 'pId': application.org.id, + 'org_name': application.org_name + }) return data def serialize_applications(self, applications): - data = [self._serialize(application) for application in applications] + data = [self._serialize_application(application) for application in applications] + return data + + @staticmethod + def _serialize_organization(org): + return { + 'id': org.id, + 'name': org.name, + 'title': org.name, + 'pId': '', + 'open': True, + 'isParent': True, + 'meta': { + 'type': 'node' + } + } + + def serialize_organizations(self, organizations): + data = [self._serialize_organization(org) for org in organizations] + return data + + @staticmethod + def filter_organizations(applications): + organizations_id = set(applications.values_list('org_id', flat=True)) + organizations = [Organization.get_instance(org_id) for org_id in organizations_id] + return organizations + + def serialize_applications_with_org(self, applications): + organizations = self.filter_organizations(applications) + data_organizations = self.serialize_organizations(organizations) + data_applications = self.serialize_applications(applications) + data = data_organizations + data_applications return data diff --git a/apps/applications/migrations/0007_auto_20201119_1110.py b/apps/applications/migrations/0007_auto_20201119_1110.py new file mode 100644 index 000000000..e206f8404 --- /dev/null +++ b/apps/applications/migrations/0007_auto_20201119_1110.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-11-19 03:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0006_application'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='attrs', + field=models.JSONField(), + ), + ] diff --git a/apps/applications/models/application.py b/apps/applications/models/application.py index 8bf2c7401..1c8ae98f0 100644 --- a/apps/applications/models/application.py +++ b/apps/applications/models/application.py @@ -2,7 +2,6 @@ from itertools import chain from django.db import models from django.utils.translation import ugettext_lazy as _ -from django_mysql.models import JSONField, QuerySet from orgs.mixins.models import OrgModelMixin from common.mixins import CommonModelMixin @@ -123,7 +122,7 @@ class Application(CommonModelMixin, OrgModelMixin): domain = models.ForeignKey('assets.Domain', null=True, blank=True, related_name='applications', verbose_name=_("Domain"), on_delete=models.SET_NULL) category = models.CharField(max_length=16, choices=Category.choices, verbose_name=_('Category')) type = models.CharField(max_length=16, choices=Category.get_all_type_choices(), verbose_name=_('Type')) - attrs = JSONField() + attrs = models.JSONField() comment = models.TextField( max_length=128, default='', blank=True, verbose_name=_('Comment') ) diff --git a/apps/applications/serializers/application.py b/apps/applications/serializers/application.py index e44e0ab95..5ffcc65fb 100644 --- a/apps/applications/serializers/application.py +++ b/apps/applications/serializers/application.py @@ -27,10 +27,8 @@ class ApplicationSerializer(BulkOrgResourceModelSerializer): ] def create(self, validated_data): - attrs = validated_data.pop('attrs', {}) + validated_data['attrs'] = validated_data.pop('attrs', {}) instance = super().create(validated_data) - instance.attrs = attrs - instance.save() return instance def update(self, instance, validated_data): diff --git a/apps/applications/serializers/database_app.py b/apps/applications/serializers/database_app.py index ac65764cd..8b983ed87 100644 --- a/apps/applications/serializers/database_app.py +++ b/apps/applications/serializers/database_app.py @@ -12,9 +12,8 @@ from .. import models class DBAttrsSerializer(serializers.Serializer): host = serializers.CharField(max_length=128, label=_('Host')) port = serializers.IntegerField(label=_('Port')) - database = serializers.CharField( - max_length=128, required=False, allow_blank=True, allow_null=True, label=_('Database') - ) + # 添加allow_null=True,兼容之前数据库中database字段为None的情况 + database = serializers.CharField(max_length=128, required=True, allow_null=True, label=_('Database')) class MySQLAttrsSerializer(DBAttrsSerializer): diff --git a/apps/assets/api/mixin.py b/apps/assets/api/mixin.py index 4374f399a..54eb91b41 100644 --- a/apps/assets/api/mixin.py +++ b/apps/assets/api/mixin.py @@ -69,6 +69,7 @@ class SerializeToTreeNodeMixin: 'ip': asset.ip, 'protocols': asset.protocols_as_list, 'platform': asset.platform_base, + 'org_name': asset.org_name }, } } diff --git a/apps/assets/api/node.py b/apps/assets/api/node.py index 5eb91e575..924529ce1 100644 --- a/apps/assets/api/node.py +++ b/apps/assets/api/node.py @@ -5,11 +5,13 @@ from collections import namedtuple, defaultdict from rest_framework import status from rest_framework.serializers import ValidationError from rest_framework.response import Response +from rest_framework.decorators import action from django.utils.translation import ugettext_lazy as _ from django.shortcuts import get_object_or_404, Http404 from django.utils.decorators import method_decorator from django.db.models.signals import m2m_changed +from common.const.http import POST from common.exceptions import SomeoneIsDoingThis from common.const.signals import PRE_REMOVE, POST_REMOVE from assets.models import Asset @@ -19,6 +21,8 @@ from common.const.distributed_lock_key import UPDATE_NODE_TREE_LOCK_KEY from orgs.mixins.api import OrgModelViewSet from orgs.mixins import generics from orgs.lock import org_level_transaction_lock +from orgs.utils import current_org +from assets.tasks import check_node_assets_amount_task from ..hands import IsOrgAdmin from ..models import Node from ..tasks import ( @@ -46,6 +50,11 @@ class NodeViewSet(OrgModelViewSet): permission_classes = (IsOrgAdmin,) serializer_class = serializers.NodeSerializer + @action(methods=[POST], detail=False, url_name='launch-check-assets-amount-task') + def launch_check_assets_amount_task(self, request): + task = check_node_assets_amount_task.delay(current_org.id) + return Response(data={'task': task.id}) + # 仅支持根节点指直接创建,子节点下的节点需要通过children接口创建 def perform_create(self, serializer): child_key = Node.org_root().get_next_child_key() @@ -61,6 +70,9 @@ class NodeViewSet(OrgModelViewSet): def destroy(self, request, *args, **kwargs): node = self.get_object() + if node.is_org_root(): + error = _("You can't delete the root node ({})".format(node.value)) + return Response(data={'error': error}, status=status.HTTP_403_FORBIDDEN) if node.has_children_or_has_assets(): error = _("Deletion failed and the node contains children or assets") return Response(data={'error': error}, status=status.HTTP_403_FORBIDDEN) @@ -173,7 +185,7 @@ class NodeChildrenAsTreeApi(SerializeToTreeNodeMixin, NodeChildrenApi): return [] assets = self.instance.get_assets().only( "id", "hostname", "ip", "os", - "org_id", "protocols", + "org_id", "protocols", "is_active" ) return self.serialize_assets(assets, self.instance.key) @@ -201,10 +213,8 @@ class NodeAddChildrenApi(generics.UpdateAPIView): def put(self, request, *args, **kwargs): instance = self.get_object() nodes_id = request.data.get("nodes") - children = [get_object_or_none(Node, id=pk) for pk in nodes_id] + children = Node.objects.filter(id__in=nodes_id) for node in children: - if not node: - continue node.parent = instance return Response("OK") diff --git a/apps/assets/api/system_user.py b/apps/assets/api/system_user.py index 59be0fa68..70bbe376e 100644 --- a/apps/assets/api/system_user.py +++ b/apps/assets/api/system_user.py @@ -3,7 +3,8 @@ from django.shortcuts import get_object_or_404 from rest_framework.response import Response from common.utils import get_logger -from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsAppUser +from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser +from common.drf.filters import CustomFilter from orgs.mixins.api import OrgBulkModelViewSet from orgs.mixins import generics from orgs.utils import tmp_to_org @@ -12,7 +13,7 @@ from .. import serializers from ..serializers import SystemUserWithAuthInfoSerializer from ..tasks import ( push_system_user_to_assets_manual, test_system_user_connectivity_manual, - push_system_user_a_asset_manual, + push_system_user_to_assets ) @@ -82,18 +83,18 @@ class SystemUserTaskApi(generics.CreateAPIView): permission_classes = (IsOrgAdmin,) serializer_class = serializers.SystemUserTaskSerializer - def do_push(self, system_user, asset=None): - if asset is None: + def do_push(self, system_user, assets_id=None): + if assets_id is None: task = push_system_user_to_assets_manual.delay(system_user) else: username = self.request.query_params.get('username') - task = push_system_user_a_asset_manual.delay( - system_user, asset, username=username + task = push_system_user_to_assets.delay( + system_user.id, assets_id, username=username ) return task @staticmethod - def do_test(system_user, asset=None): + def do_test(system_user): task = test_system_user_connectivity_manual.delay(system_user) return task @@ -104,11 +105,16 @@ class SystemUserTaskApi(generics.CreateAPIView): def perform_create(self, serializer): action = serializer.validated_data["action"] asset = serializer.validated_data.get('asset') + assets = serializer.validated_data.get('assets') or [] + system_user = self.get_object() if action == 'push': - task = self.do_push(system_user, asset) + assets = [asset] if asset else assets + assets_id = [asset.id for asset in assets] + assets_id = assets_id if assets_id else None + task = self.do_push(system_user, assets_id) else: - task = self.do_test(system_user, asset) + task = self.do_test(system_user) data = getattr(serializer, '_data', {}) data["task"] = task.id setattr(serializer, '_data', data) diff --git a/apps/assets/migrations/0063_migrate_default_node_key.py b/apps/assets/migrations/0063_migrate_default_node_key.py new file mode 100644 index 000000000..fc294264f --- /dev/null +++ b/apps/assets/migrations/0063_migrate_default_node_key.py @@ -0,0 +1,72 @@ +# Generated by Jiangjie.Bai on 2020-12-01 10:47 + +from django.db import migrations +from django.db.models import Q + +default_node_value = 'Default' # Always +old_default_node_key = '0' # Version <= 1.4.3 +new_default_node_key = '1' # Version >= 1.4.4 + + +def compute_parent_key(key): + try: + return key[:key.rindex(':')] + except ValueError: + return '' + + +def migrate_default_node_key(apps, schema_editor): + """ 将已经存在的Default节点的key从0修改为1 """ + # 1.4.3版本中Default节点的key为0 + print('') + Node = apps.get_model('assets', 'Node') + Asset = apps.get_model('assets', 'Asset') + + # key为0的节点 + old_default_node = Node.objects.filter(key=old_default_node_key, value=default_node_value).first() + if not old_default_node: + print(f'Check old default node `key={old_default_node_key} value={default_node_value}` not exists') + return + print(f'Check old default node `key={old_default_node_key} value={default_node_value}` exists') + # key为1的节点 + new_default_node = Node.objects.filter(key=new_default_node_key, value=default_node_value).first() + if new_default_node: + print(f'Check new default node `key={new_default_node_key} value={default_node_value}` exists') + all_assets = Asset.objects.filter( + Q(nodes__key__startswith=f'{new_default_node_key}:') | Q(nodes__key=new_default_node_key) + ).distinct() + if all_assets: + print(f'Check new default node has assets (count: {len(all_assets)})') + return + all_children = Node.objects.filter(key__startswith=f'{new_default_node_key}:') + if all_children: + print(f'Check new default node has children nodes (count: {len(all_children)})') + return + print(f'Check new default node not has assets and children nodes, delete it.') + new_default_node.delete() + # 执行修改 + print(f'Modify old default node `key` from `{old_default_node_key}` to `{new_default_node_key}`') + nodes = Node.objects.filter( + Q(key__istartswith=f'{old_default_node_key}:') | Q(key=old_default_node_key) + ) + for node in nodes: + old_key = node.key + key_list = old_key.split(':', maxsplit=1) + key_list[0] = new_default_node_key + new_key = ':'.join(key_list) + node.key = new_key + node.parent_key = compute_parent_key(node.key) + # 批量更新 + print(f'Bulk update nodes `key` and `parent_key`, (count: {len(nodes)})') + Node.objects.bulk_update(nodes, ['key', 'parent_key']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0062_auto_20201117_1938'), + ] + + operations = [ + migrations.RunPython(migrate_default_node_key) + ] diff --git a/apps/assets/migrations/0064_auto_20201203_1100.py b/apps/assets/migrations/0064_auto_20201203_1100.py new file mode 100644 index 000000000..a8119a883 --- /dev/null +++ b/apps/assets/migrations/0064_auto_20201203_1100.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2020-12-03 03:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0063_migrate_default_node_key'), + ] + + operations = [ + migrations.AlterModelOptions( + name='node', + options={'ordering': ['parent_key', 'value'], 'verbose_name': 'Node'}, + ), + ] diff --git a/apps/assets/models/node.py b/apps/assets/models/node.py index e88afccdc..f8d5f9691 100644 --- a/apps/assets/models/node.py +++ b/apps/assets/models/node.py @@ -103,7 +103,7 @@ class FamilyMixin: if value is None: value = child_key child = self.__class__.objects.create( - id=_id, key=child_key, value=value, parent_key=self.key, + id=_id, key=child_key, value=value ) return child @@ -354,7 +354,8 @@ class SomeNodesMixin: def org_root(cls): root = cls.objects.filter(parent_key='')\ .filter(key__regex=r'^[0-9]+$')\ - .exclude(key__startswith='-') + .exclude(key__startswith='-')\ + .order_by('key') if root: return root[0] else: @@ -411,7 +412,7 @@ class Node(OrgModelMixin, SomeNodesMixin, FamilyMixin, NodeAssetsMixin): class Meta: verbose_name = _("Node") - ordering = ['value'] + ordering = ['parent_key', 'value'] def __str__(self): return self.full_value diff --git a/apps/assets/serializers/asset.py b/apps/assets/serializers/asset.py index 37de2e7fd..ce8b54ca9 100644 --- a/apps/assets/serializers/asset.py +++ b/apps/assets/serializers/asset.py @@ -98,9 +98,6 @@ class AssetSerializer(BulkOrgResourceModelSerializer): fields_as = list(annotates_fields.keys()) fields = fields_small + fields_fk + fields_m2m + fields_as read_only_fields = [ - 'vendor', 'model', 'sn', 'cpu_model', 'cpu_count', - 'cpu_cores', 'cpu_vcpus', 'memory', 'disk_total', 'disk_info', - 'os', 'os_version', 'os_arch', 'hostname_raw', 'created_by', 'date_created', ] + fields_as diff --git a/apps/assets/serializers/system_user.py b/apps/assets/serializers/system_user.py index a4f6d933c..fb8d4df97 100644 --- a/apps/assets/serializers/system_user.py +++ b/apps/assets/serializers/system_user.py @@ -257,4 +257,8 @@ class SystemUserTaskSerializer(serializers.Serializer): asset = serializers.PrimaryKeyRelatedField( queryset=Asset.objects, allow_null=True, required=False, write_only=True ) + assets = serializers.PrimaryKeyRelatedField( + queryset=Asset.objects, allow_null=True, required=False, write_only=True, + many=True + ) task = serializers.CharField(read_only=True) diff --git a/apps/assets/signals_handler.py b/apps/assets/signals_handler.py index 1c2813aa9..061d7d84c 100644 --- a/apps/assets/signals_handler.py +++ b/apps/assets/signals_handler.py @@ -4,7 +4,7 @@ from operator import add, sub from assets.utils import is_asset_exists_in_node from django.db.models.signals import ( - post_save, m2m_changed, pre_delete, post_delete + post_save, m2m_changed, pre_delete, post_delete, pre_save ) from django.db.models import Q, F from django.dispatch import receiver @@ -37,6 +37,11 @@ def test_asset_conn_on_created(asset): test_asset_connectivity_util.delay([asset]) +@receiver(pre_save, sender=Node) +def on_node_pre_save(sender, instance: Node, **kwargs): + instance.parent_key = instance.compute_parent_key() + + @receiver(post_save, sender=Asset) @on_transaction_commit def on_asset_created_or_update(sender, instance=None, created=False, **kwargs): @@ -73,6 +78,7 @@ def on_system_user_update(instance: SystemUser, created, **kwargs): @receiver(m2m_changed, sender=SystemUser.assets.through) +@on_transaction_commit def on_system_user_assets_change(instance, action, model, pk_set, **kwargs): """ 当系统用户和资产关系发生变化时,应该重新推送系统用户到新添加的资产中 @@ -91,25 +97,29 @@ def on_system_user_assets_change(instance, action, model, pk_set, **kwargs): @receiver(m2m_changed, sender=SystemUser.users.through) -def on_system_user_users_change(sender, instance=None, action='', model=None, pk_set=None, **kwargs): +@on_transaction_commit +def on_system_user_users_change(sender, instance: SystemUser, action, model, pk_set, reverse, **kwargs): """ 当系统用户和用户关系发生变化时,应该重新推送系统用户资产中 """ if action != POST_ADD: return + + if reverse: + raise M2MReverseNotAllowed + if not instance.username_same_with_user: return + logger.debug("System user users change signal recv: {}".format(instance)) - queryset = model.objects.filter(pk__in=pk_set) - if model == SystemUser: - system_users = queryset - else: - system_users = [instance] - for s in system_users: - push_system_user_to_assets_manual.delay(s) + usernames = model.objects.filter(pk__in=pk_set).values_list('username', flat=True) + + for username in usernames: + push_system_user_to_assets_manual.delay(instance, username) @receiver(m2m_changed, sender=SystemUser.nodes.through) +@on_transaction_commit def on_system_user_nodes_change(sender, instance=None, action=None, model=None, pk_set=None, **kwargs): """ 当系统用户和节点关系发生变化时,应该将节点下资产关联到新的系统用户上 diff --git a/apps/assets/tasks/nodes_amount.py b/apps/assets/tasks/nodes_amount.py index 4d53be525..e1e437797 100644 --- a/apps/assets/tasks/nodes_amount.py +++ b/apps/assets/tasks/nodes_amount.py @@ -1,14 +1,27 @@ from celery import shared_task +from django.utils.translation import gettext_lazy as _ +from orgs.models import Organization +from orgs.utils import tmp_to_org +from ops.celery.decorator import register_as_period_task from assets.utils import check_node_assets_amount + +from common.utils.lock import AcquireFailed from common.utils import get_logger -from common.utils.timezone import now logger = get_logger(__file__) -@shared_task() -def check_node_assets_amount_celery_task(): - logger.info(f'>>> {now()} begin check_node_assets_amount_celery_task ...') - check_node_assets_amount() - logger.info(f'>>> {now()} end check_node_assets_amount_celery_task ...') +@shared_task(queue='celery_heavy_tasks') +def check_node_assets_amount_task(org_id=Organization.ROOT_ID): + try: + with tmp_to_org(Organization.get_instance(org_id)): + check_node_assets_amount() + except AcquireFailed: + logger.error(_('The task of self-checking is already running and cannot be started repeatedly')) + + +@register_as_period_task(crontab='0 2 * * *') +@shared_task(queue='celery_heavy_tasks') +def check_node_assets_amount_period_task(): + check_node_assets_amount_task() diff --git a/apps/assets/tasks/push_system_user.py b/apps/assets/tasks/push_system_user.py index 0bb9be407..7506d1fe2 100644 --- a/apps/assets/tasks/push_system_user.py +++ b/apps/assets/tasks/push_system_user.py @@ -2,13 +2,13 @@ from itertools import groupby from celery import shared_task -from common.db.utils import get_object_if_need, get_objects_if_need, get_objects +from common.db.utils import get_object_if_need, get_objects from django.utils.translation import ugettext as _ from django.db.models import Empty from common.utils import encrypt_password, get_logger -from assets.models import SystemUser, Asset -from orgs.utils import org_aware_func +from assets.models import SystemUser, Asset, AuthBook +from orgs.utils import org_aware_func, tmp_to_root_org from . import const from .utils import clean_ansible_task_hosts, group_asset_by_platform @@ -190,15 +190,12 @@ def get_push_system_user_tasks(system_user, platform="unixlike", username=None): @org_aware_func("system_user") def push_system_user_util(system_user, assets, task_name, username=None): from ops.utils import update_or_create_ansible_task - hosts = clean_ansible_task_hosts(assets, system_user=system_user) - if not hosts: + assets = clean_ansible_task_hosts(assets, system_user=system_user) + if not assets: return {} - platform_hosts_map = {} - hosts_sorted = sorted(hosts, key=group_asset_by_platform) - platform_hosts = groupby(hosts_sorted, key=group_asset_by_platform) - for i in platform_hosts: - platform_hosts_map[i[0]] = list(i[1]) + assets_sorted = sorted(assets, key=group_asset_by_platform) + platform_hosts = groupby(assets_sorted, key=group_asset_by_platform) def run_task(_tasks, _hosts): if not _tasks: @@ -209,27 +206,59 @@ def push_system_user_util(system_user, assets, task_name, username=None): ) task.run() - for platform, _hosts in platform_hosts_map.items(): - if not _hosts: + if system_user.username_same_with_user: + if username is None: + # 动态系统用户,但是没有指定 username + usernames = list(system_user.users.all().values_list('username', flat=True).distinct()) + else: + usernames = [username] + else: + # 非动态系统用户指定 username 无效 + assert username is None, 'Only Dynamic user can assign `username`' + usernames = [system_user.username] + + for platform, _assets in platform_hosts: + _assets = list(_assets) + if not _assets: continue print(_("Start push system user for platform: [{}]").format(platform)) - print(_("Hosts count: {}").format(len(_hosts))) + print(_("Hosts count: {}").format(len(_assets))) - # 如果没有特殊密码设置,就不需要单独推送某台机器了 - if not system_user.has_special_auth(username=username): - logger.debug("System user not has special auth") - tasks = get_push_system_user_tasks(system_user, platform, username=username) - run_task(tasks, _hosts) - continue + id_asset_map = {_asset.id: _asset for _asset in _assets} + assets_id = id_asset_map.keys() + no_special_auth = [] + special_auth_set = set() - for _host in _hosts: - system_user.load_asset_special_auth(_host, username=username) - tasks = get_push_system_user_tasks(system_user, platform, username=username) - run_task(tasks, [_host]) + auth_books = AuthBook.objects.filter(username__in=usernames, asset_id__in=assets_id) + + for auth_book in auth_books: + special_auth_set.add((auth_book.username, auth_book.asset_id)) + + for _username in usernames: + no_special_assets = [] + for asset_id in assets_id: + if (_username, asset_id) not in special_auth_set: + no_special_assets.append(id_asset_map[asset_id]) + if no_special_assets: + no_special_auth.append((_username, no_special_assets)) + + for _username, no_special_assets in no_special_auth: + tasks = get_push_system_user_tasks(system_user, platform, username=_username) + run_task(tasks, no_special_assets) + + for auth_book in auth_books: + system_user._merge_auth(auth_book) + tasks = get_push_system_user_tasks(system_user, platform, username=auth_book.username) + asset = id_asset_map[auth_book.asset_id] + run_task(tasks, [asset]) @shared_task(queue="ansible") +@tmp_to_root_org() def push_system_user_to_assets_manual(system_user, username=None): + """ + 将系统用户推送到与它关联的所有资产上 + """ system_user = get_object_if_need(SystemUser, system_user) assets = system_user.get_related_assets() task_name = _("Push system users to assets: {}").format(system_user.name) @@ -237,7 +266,11 @@ def push_system_user_to_assets_manual(system_user, username=None): @shared_task(queue="ansible") +@tmp_to_root_org() def push_system_user_a_asset_manual(system_user, asset, username=None): + """ + 将系统用户推送到一个资产上 + """ if username is None: username = system_user.username task_name = _("Push system users to asset: {}({}) => {}").format( @@ -247,10 +280,15 @@ def push_system_user_a_asset_manual(system_user, asset, username=None): @shared_task(queue="ansible") +@tmp_to_root_org() def push_system_user_to_assets(system_user_id, assets_id, username=None): + """ + 推送系统用户到指定的若干资产上 + """ system_user = SystemUser.objects.get(id=system_user_id) assets = get_objects(Asset, assets_id) task_name = _("Push system users to assets: {}").format(system_user.name) + return push_system_user_util(system_user, assets, task_name, username=username) # @shared_task diff --git a/apps/assets/utils.py b/apps/assets/utils.py index 7fc5a19ac..2805ac034 100644 --- a/apps/assets/utils.py +++ b/apps/assets/utils.py @@ -1,8 +1,11 @@ # ~*~ coding: utf-8 ~*~ # +import time + from django.db.models import Q from common.utils import get_logger, dict_get_any, is_uuid, get_object_or_none +from common.utils.lock import DistributedLock from common.http import is_true from .models import Asset, Node @@ -10,17 +13,21 @@ from .models import Asset, Node logger = get_logger(__file__) +@DistributedLock(name="assets.node.check_node_assets_amount", blocking=False) def check_node_assets_amount(): for node in Node.objects.all(): + logger.info(f'Check node assets amount: {node}') assets_amount = Asset.objects.filter( Q(nodes__key__istartswith=f'{node.key}:') | Q(nodes=node) ).distinct().count() if node.assets_amount != assets_amount: - print(f'>>> <Node:{node.key}> wrong assets amount ' - f'{node.assets_amount} right is {assets_amount}') + logger.warn(f'Node wrong assets amount <Node:{node.key}> ' + f'{node.assets_amount} right is {assets_amount}') node.assets_amount = assets_amount node.save() + # 防止自检程序给数据库的压力太大 + time.sleep(0.1) def is_asset_exists_in_node(asset_pk, node_key): diff --git a/apps/audits/migrations/0011_userloginlog_backend.py b/apps/audits/migrations/0011_userloginlog_backend.py new file mode 100644 index 000000000..5a708b198 --- /dev/null +++ b/apps/audits/migrations/0011_userloginlog_backend.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-12-09 03:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audits', '0010_auto_20200811_1122'), + ] + + operations = [ + migrations.AddField( + model_name='userloginlog', + name='backend', + field=models.CharField(default='', max_length=32, verbose_name='Login backend'), + ), + ] diff --git a/apps/audits/models.py b/apps/audits/models.py index c959bc35c..2b28943bb 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -105,6 +105,7 @@ class UserLoginLog(models.Model): reason = models.CharField(default='', max_length=128, blank=True, verbose_name=_('Reason')) status = models.BooleanField(max_length=2, default=True, choices=STATUS_CHOICE, verbose_name=_('Status')) datetime = models.DateTimeField(default=timezone.now, verbose_name=_('Date login')) + backend = models.CharField(max_length=32, default='', verbose_name=_('Login backend')) @classmethod def get_login_logs(cls, date_from=None, date_to=None, user=None, keyword=None): diff --git a/apps/audits/serializers.py b/apps/audits/serializers.py index 0815226a4..74c8f598c 100644 --- a/apps/audits/serializers.py +++ b/apps/audits/serializers.py @@ -31,7 +31,8 @@ class UserLoginLogSerializer(serializers.ModelSerializer): model = models.UserLoginLog fields = ( 'id', 'username', 'type', 'type_display', 'ip', 'city', 'user_agent', - 'mfa', 'reason', 'status', 'status_display', 'datetime', 'mfa_display' + 'mfa', 'reason', 'status', 'status_display', 'datetime', 'mfa_display', + 'backend' ) extra_kwargs = { "user_agent": {'label': _('User agent')} diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index b95f6fbdf..e25bd9be9 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -5,6 +5,8 @@ from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.db import transaction from django.utils import timezone +from django.contrib.auth import BACKEND_SESSION_KEY +from django.utils.translation import ugettext_lazy as _ from rest_framework.renderers import JSONRenderer from rest_framework.request import Request @@ -32,6 +34,19 @@ MODELS_NEED_RECORD = ( ) +LOGIN_BACKEND = { + 'PublicKeyAuthBackend': _('SSH Key'), + 'RadiusBackend': User.Source.radius.label, + 'RadiusRealmBackend': User.Source.radius.label, + 'LDAPAuthorizationBackend': User.Source.ldap.label, + 'ModelBackend': _('Password'), + 'SSOAuthentication': _('SSO'), + 'CASBackend': User.Source.cas.label, + 'OIDCAuthCodeBackend': User.Source.openid.label, + 'OIDCAuthPasswordBackend': User.Source.openid.label, +} + + def create_operate_log(action, sender, resource): user = current_request.user if current_request else None if not user or not user.is_authenticated: @@ -109,6 +124,12 @@ def on_audits_log_create(sender, instance=None, **kwargs): sys_logger.info(msg) +def get_login_backend(request): + backend = request.session.get(BACKEND_SESSION_KEY, '') + backend = backend.rsplit('.', maxsplit=1)[-1] + return LOGIN_BACKEND.get(backend, '') + + def generate_data(username, request): user_agent = request.META.get('HTTP_USER_AGENT', '') login_ip = get_request_ip(request) or '0.0.0.0' @@ -122,7 +143,8 @@ def generate_data(username, request): 'ip': login_ip, 'type': login_type, 'user_agent': user_agent, - 'datetime': timezone.now() + 'datetime': timezone.now(), + 'backend': get_login_backend(request) } return data diff --git a/apps/authentication/backends/api.py b/apps/authentication/backends/api.py index 4cdbbbe53..1fd315abb 100644 --- a/apps/authentication/backends/api.py +++ b/apps/authentication/backends/api.py @@ -6,7 +6,7 @@ import time from django.core.cache import cache from django.utils.translation import ugettext as _ -from django.utils.six import text_type +from six import text_type from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend from rest_framework import HTTP_HEADER_ENCODING diff --git a/apps/authentication/backends/radius.py b/apps/authentication/backends/radius.py index 4301e17bf..6798e72f2 100644 --- a/apps/authentication/backends/radius.py +++ b/apps/authentication/backends/radius.py @@ -23,7 +23,7 @@ class CreateUserMixin: email_suffix = settings.EMAIL_SUFFIX email = '{}@{}'.format(username, email_suffix) user = User(username=username, name=username, email=email) - user.source = user.SOURCE_RADIUS + user.source = user.Source.radius.value user.save() return user diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 26363363e..8cea830e1 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -218,5 +218,14 @@ class PasswdTooSimple(JMSException): default_detail = _('Your password is too simple, please change it for security') def __init__(self, url, *args, **kwargs): - super(PasswdTooSimple, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + self.url = url + + +class PasswordRequireResetError(JMSException): + default_code = 'passwd_has_expired' + default_detail = _('Your password has expired, please reset before logging in') + + def __init__(self, url, *args, **kwargs): + super().__init__(*args, **kwargs) self.url = url diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py index 2f03d935b..fe28edb68 100644 --- a/apps/authentication/forms.py +++ b/apps/authentication/forms.py @@ -4,7 +4,7 @@ from django import forms from django.conf import settings from django.utils.translation import gettext_lazy as _ -from captcha.fields import CaptchaField +from captcha.fields import CaptchaField, CaptchaTextInput class UserLoginForm(forms.Form): @@ -26,8 +26,12 @@ class UserCheckOtpCodeForm(forms.Form): otp_code = forms.CharField(label=_('MFA code'), max_length=6) +class CustomCaptchaTextInput(CaptchaTextInput): + template_name = 'authentication/_captcha_field.html' + + class CaptchaMixin(forms.Form): - captcha = CaptchaField() + captcha = CaptchaField(widget=CustomCaptchaTextInput) class ChallengeMixin(forms.Form): diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 1d9335743..87b989283 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -110,9 +110,8 @@ class AuthMixin: raise CredentialError(error=errors.reason_user_inactive) elif not user.is_active: raise CredentialError(error=errors.reason_user_inactive) - elif user.password_has_expired: - raise CredentialError(error=errors.reason_password_expired) + self._check_password_require_reset_or_not(user) self._check_passwd_is_too_simple(user, password) clean_failed_count(username, ip) @@ -123,20 +122,34 @@ class AuthMixin: return user @classmethod - def _check_passwd_is_too_simple(cls, user, password): + def generate_reset_password_url_with_flash_msg(cls, user: User, flash_view_name): + reset_passwd_url = reverse('authentication:reset-password') + query_str = urlencode({ + 'token': user.generate_reset_token() + }) + reset_passwd_url = f'{reset_passwd_url}?{query_str}' + + flash_page_url = reverse(flash_view_name) + query_str = urlencode({ + 'redirect_url': reset_passwd_url + }) + return f'{flash_page_url}?{query_str}' + + @classmethod + def _check_passwd_is_too_simple(cls, user: User, password): if user.is_superuser and password == 'admin': - reset_passwd_url = reverse('authentication:reset-password') - query_str = urlencode({ - 'token': user.generate_reset_token() - }) - reset_passwd_url = f'{reset_passwd_url}?{query_str}' + url = cls.generate_reset_password_url_with_flash_msg( + user, 'authentication:passwd-too-simple-flash-msg' + ) + raise errors.PasswdTooSimple(url) - flash_page_url = reverse('authentication:passwd-too-simple-flash-msg') - query_str = urlencode({ - 'redirect_url': reset_passwd_url - }) - - raise errors.PasswdTooSimple(f'{flash_page_url}?{query_str}') + @classmethod + def _check_password_require_reset_or_not(cls, user: User): + if user.password_has_expired: + url = cls.generate_reset_password_url_with_flash_msg( + user, 'authentication:passwd-has-expired-flash-msg' + ) + raise errors.PasswordRequireResetError(url) def check_user_auth_if_need(self, decrypt_passwd=False): request = self.request diff --git a/apps/authentication/models.py b/apps/authentication/models.py index 592a43674..a205a5190 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -1,11 +1,9 @@ import uuid -from functools import partial from django.utils import timezone from django.utils.translation import ugettext_lazy as _, ugettext as __ from rest_framework.authtoken.models import Token from django.conf import settings -from django.utils.crypto import get_random_string from common.db import models from common.mixins.models import CommonModelMixin diff --git a/apps/authentication/templates/authentication/_captcha_field.html b/apps/authentication/templates/authentication/_captcha_field.html new file mode 100644 index 000000000..a190aacb7 --- /dev/null +++ b/apps/authentication/templates/authentication/_captcha_field.html @@ -0,0 +1,29 @@ +{% load i18n %} +{% spaceless %} + <img src="{{ image }}" alt="captcha" class="captcha" /> + <div class="row" style="padding-bottom: 10px"> + <div class="col-sm-6"> + <div class="input-group-prepend"> + {% if audio %} + <a title="{% trans "Play CAPTCHA as audio file" %}" href="{{ audio }}"> + {% endif %} + </div> + {% include "django/forms/widgets/multiwidget.html" %} + </div> + </div> + <script> + var placeholder = '{% trans "Captcha" %}' + function refresh_captcha() { + $.getJSON("{% url "captcha-refresh" %}", + function (result) { + $('.captcha').attr('src', result['image_url']); + $('#id_captcha_0').val(result['key']) + }) + } + $(document).ready(function () { + $('.captcha').click(refresh_captcha) + $('#id_captcha_1').addClass('form-control').attr('placeholder', placeholder) + }) + </script> + +{% endspaceless %} \ No newline at end of file diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index 467e32d0d..a95342fa6 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -22,6 +22,7 @@ urlpatterns = [ name='forgot-password-sendmail-success'), path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'), path('password/too-simple-flash-msg/', views.FlashPasswdTooSimpleMsgView.as_view(), name='passwd-too-simple-flash-msg'), + path('password/has-expired-msg/', views.FlashPasswdHasExpiredMsgView.as_view(), name='passwd-has-expired-flash-msg'), path('password/reset/success/', users_view.UserResetPasswordSuccessView.as_view(), name='reset-password-success'), path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'), diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 4210315c5..dcff66905 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -32,7 +32,7 @@ from ..forms import get_user_login_form_cls __all__ = [ 'UserLoginView', 'UserLogoutView', 'UserLoginGuardView', 'UserLoginWaitConfirmView', - 'FlashPasswdTooSimpleMsgView', + 'FlashPasswdTooSimpleMsgView', 'FlashPasswdHasExpiredMsgView' ] @@ -96,7 +96,7 @@ class UserLoginView(mixins.AuthMixin, FormView): new_form._errors = form.errors context = self.get_context_data(form=new_form) return self.render_to_response(context) - except errors.PasswdTooSimple as e: + except (errors.PasswdTooSimple, errors.PasswordRequireResetError) as e: return redirect(e.url) self.clear_rsa_key() return self.redirect_to_guard_view() @@ -250,3 +250,18 @@ class FlashPasswdTooSimpleMsgView(TemplateView): 'auto_redirect': True, } return self.render_to_response(context) + + +@method_decorator(never_cache, name='dispatch') +class FlashPasswdHasExpiredMsgView(TemplateView): + template_name = 'flash_message_standalone.html' + + def get(self, request, *args, **kwargs): + context = { + 'title': _('Please change your password'), + 'messages': _('Your password has expired, please reset before logging in'), + 'interval': 5, + 'redirect_url': request.GET.get('redirect_url'), + 'auto_redirect': True, + } + return self.render_to_response(context) diff --git a/apps/common/drf/parsers/__init__.py b/apps/common/drf/parsers/__init__.py index 671c86586..75dc28249 100644 --- a/apps/common/drf/parsers/__init__.py +++ b/apps/common/drf/parsers/__init__.py @@ -1 +1,2 @@ -from .csv import * \ No newline at end of file +from .csv import * +from .excel import * \ No newline at end of file diff --git a/apps/common/drf/parsers/base.py b/apps/common/drf/parsers/base.py new file mode 100644 index 000000000..605dcdd08 --- /dev/null +++ b/apps/common/drf/parsers/base.py @@ -0,0 +1,132 @@ +import abc +import json +import codecs +from django.utils.translation import ugettext_lazy as _ +from rest_framework.parsers import BaseParser +from rest_framework import status +from rest_framework.exceptions import ParseError, APIException +from common.utils import get_logger + +logger = get_logger(__file__) + + +class FileContentOverflowedError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_code = 'file_content_overflowed' + default_detail = _('The file content overflowed (The maximum length `{}` bytes)') + + +class BaseFileParser(BaseParser): + + FILE_CONTENT_MAX_LENGTH = 1024 * 1024 * 10 + + serializer_cls = None + + def check_content_length(self, meta): + content_length = int(meta.get('CONTENT_LENGTH', meta.get('HTTP_CONTENT_LENGTH', 0))) + if content_length > self.FILE_CONTENT_MAX_LENGTH: + msg = FileContentOverflowedError.default_detail.format(self.FILE_CONTENT_MAX_LENGTH) + logger.error(msg) + raise FileContentOverflowedError(msg) + + @staticmethod + def get_stream_data(stream): + stream_data = stream.read() + stream_data = stream_data.strip(codecs.BOM_UTF8) + return stream_data + + @abc.abstractmethod + def generate_rows(self, stream_data): + raise NotImplemented + + def get_column_titles(self, rows): + return next(rows) + + def convert_to_field_names(self, column_titles): + fields_map = {} + fields = self.serializer_cls().fields + fields_map.update({v.label: k for k, v in fields.items()}) + fields_map.update({k: k for k, _ in fields.items()}) + field_names = [ + fields_map.get(column_title.strip('*'), '') + for column_title in column_titles + ] + return field_names + + @staticmethod + def _replace_chinese_quote(s): + trans_table = str.maketrans({ + '“': '"', + '”': '"', + '‘': '"', + '’': '"', + '\'': '"' + }) + return s.translate(trans_table) + + @classmethod + def process_row(cls, row): + """ + 构建json数据前的行处理 + """ + new_row = [] + for col in row: + # 转换中文引号 + col = cls._replace_chinese_quote(col) + # 列表/字典转换 + if isinstance(col, str) and ( + (col.startswith('[') and col.endswith(']')) + or + (col.startswith("{") and col.endswith("}")) + ): + col = json.loads(col) + new_row.append(col) + return new_row + + @staticmethod + def process_row_data(row_data): + """ + 构建json数据后的行数据处理 + """ + new_row_data = {} + for k, v in row_data.items(): + if isinstance(v, list) or isinstance(v, dict) or isinstance(v, str) and k.strip() and v.strip(): + new_row_data[k] = v + return new_row_data + + def generate_data(self, fields_name, rows): + data = [] + for row in rows: + # 空行不处理 + if not any(row): + continue + row = self.process_row(row) + row_data = dict(zip(fields_name, row)) + row_data = self.process_row_data(row_data) + data.append(row_data) + return data + + def parse(self, stream, media_type=None, parser_context=None): + parser_context = parser_context or {} + + try: + view = parser_context['view'] + meta = view.request.META + self.serializer_cls = view.get_serializer_class() + except Exception as e: + logger.debug(e, exc_info=True) + raise ParseError('The resource does not support imports!') + + self.check_content_length(meta) + + try: + stream_data = self.get_stream_data(stream) + rows = self.generate_rows(stream_data) + column_titles = self.get_column_titles(rows) + field_names = self.convert_to_field_names(column_titles) + data = self.generate_data(field_names, rows) + return data + except Exception as e: + logger.error(e, exc_info=True) + raise ParseError('Parse error! ({})'.format(self.media_type)) + diff --git a/apps/common/drf/parsers/csv.py b/apps/common/drf/parsers/csv.py index de0d14ea7..0dd11aa4b 100644 --- a/apps/common/drf/parsers/csv.py +++ b/apps/common/drf/parsers/csv.py @@ -1,32 +1,13 @@ # ~*~ coding: utf-8 ~*~ # -import json import chardet -import codecs import unicodecsv -from django.utils.translation import ugettext as _ -from rest_framework.parsers import BaseParser -from rest_framework.exceptions import ParseError, APIException -from rest_framework import status - -from common.utils import get_logger - -logger = get_logger(__file__) +from .base import BaseFileParser -class CsvDataTooBig(APIException): - status_code = status.HTTP_400_BAD_REQUEST - default_code = 'csv_data_too_big' - default_detail = _('The max size of CSV is %d bytes') - - -class JMSCSVParser(BaseParser): - """ - Parses CSV file to serializer data - """ - CSV_UPLOAD_MAX_SIZE = 1024 * 1024 * 10 +class CSVFileParser(BaseFileParser): media_type = 'text/csv' @@ -38,99 +19,10 @@ class JMSCSVParser(BaseParser): for line in stream.splitlines(): yield line - @staticmethod - def _gen_rows(csv_data, charset='utf-8', **kwargs): - csv_reader = unicodecsv.reader(csv_data, encoding=charset, **kwargs) + def generate_rows(self, stream_data): + detect_result = chardet.detect(stream_data) + encoding = detect_result.get("encoding", "utf-8") + lines = self._universal_newlines(stream_data) + csv_reader = unicodecsv.reader(lines, encoding=encoding) for row in csv_reader: - if not any(row): # 空行 - continue yield row - - @staticmethod - def _get_fields_map(serializer_cls): - fields_map = {} - fields = serializer_cls().fields - fields_map.update({v.label: k for k, v in fields.items()}) - fields_map.update({k: k for k, _ in fields.items()}) - return fields_map - - @staticmethod - def _replace_chinese_quot(str_): - trans_table = str.maketrans({ - '“': '"', - '”': '"', - '‘': '"', - '’': '"', - '\'': '"' - }) - return str_.translate(trans_table) - - @classmethod - def _process_row(cls, row): - """ - 构建json数据前的行处理 - """ - _row = [] - - for col in row: - # 列表转换 - if isinstance(col, str) and col.startswith('[') and col.endswith(']'): - col = cls._replace_chinese_quot(col) - col = json.loads(col) - # 字典转换 - if isinstance(col, str) and col.startswith("{") and col.endswith("}"): - col = cls._replace_chinese_quot(col) - col = json.loads(col) - _row.append(col) - return _row - - @staticmethod - def _process_row_data(row_data): - """ - 构建json数据后的行数据处理 - """ - _row_data = {} - for k, v in row_data.items(): - if isinstance(v, list) or isinstance(v, dict)\ - or isinstance(v, str) and k.strip() and v.strip(): - _row_data[k] = v - return _row_data - - def parse(self, stream, media_type=None, parser_context=None): - parser_context = parser_context or {} - try: - view = parser_context['view'] - meta = view.request.META - serializer_cls = view.get_serializer_class() - except Exception as e: - logger.debug(e, exc_info=True) - raise ParseError('The resource does not support imports!') - - content_length = int(meta.get('CONTENT_LENGTH', meta.get('HTTP_CONTENT_LENGTH', 0))) - if content_length > self.CSV_UPLOAD_MAX_SIZE: - msg = CsvDataTooBig.default_detail % self.CSV_UPLOAD_MAX_SIZE - logger.error(msg) - raise CsvDataTooBig(msg) - - try: - stream_data = stream.read() - stream_data = stream_data.strip(codecs.BOM_UTF8) - detect_result = chardet.detect(stream_data) - encoding = detect_result.get("encoding", "utf-8") - binary = self._universal_newlines(stream_data) - rows = self._gen_rows(binary, charset=encoding) - - header = next(rows) - fields_map = self._get_fields_map(serializer_cls) - header = [fields_map.get(name.strip('*'), '') for name in header] - - data = [] - for row in rows: - row = self._process_row(row) - row_data = dict(zip(header, row)) - row_data = self._process_row_data(row_data) - data.append(row_data) - return data - except Exception as e: - logger.error(e, exc_info=True) - raise ParseError('CSV parse error!') diff --git a/apps/common/drf/parsers/excel.py b/apps/common/drf/parsers/excel.py new file mode 100644 index 000000000..c5007866c --- /dev/null +++ b/apps/common/drf/parsers/excel.py @@ -0,0 +1,14 @@ +import pyexcel +from .base import BaseFileParser + + +class ExcelFileParser(BaseFileParser): + + media_type = 'text/xlsx' + + def generate_rows(self, stream_data): + workbook = pyexcel.get_book(file_type='xlsx', file_content=stream_data) + # 默认获取第一个工作表sheet + sheet = workbook.sheet_by_index(0) + rows = sheet.rows() + return rows diff --git a/apps/common/drf/renders/__init__.py b/apps/common/drf/renders/__init__.py index f99b13586..bbefe8783 100644 --- a/apps/common/drf/renders/__init__.py +++ b/apps/common/drf/renders/__init__.py @@ -1,6 +1,7 @@ from rest_framework import renderers from .csv import * +from .excel import * class PassthroughRenderer(renderers.BaseRenderer): diff --git a/apps/common/drf/renders/base.py b/apps/common/drf/renders/base.py new file mode 100644 index 000000000..deac735cc --- /dev/null +++ b/apps/common/drf/renders/base.py @@ -0,0 +1,132 @@ +import abc +from datetime import datetime +from rest_framework.renderers import BaseRenderer +from rest_framework.utils import encoders, json + +from common.utils import get_logger + +logger = get_logger(__file__) + + +class BaseFileRenderer(BaseRenderer): + # 渲染模版标识, 导入、导出、更新模版: ['import', 'update', 'export'] + template = 'export' + serializer = None + + @staticmethod + def _check_validation_data(data): + detail_key = "detail" + if detail_key in data: + return False + return True + + @staticmethod + def _json_format_response(response_data): + return json.dumps(response_data) + + def set_response_disposition(self, response): + serializer = self.serializer + if response and hasattr(serializer, 'Meta') and hasattr(serializer.Meta, "model"): + model_name = serializer.Meta.model.__name__.lower() + now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = "{}_{}.{}".format(model_name, now, self.format) + disposition = 'attachment; filename="{}"'.format(filename) + response['Content-Disposition'] = disposition + + def get_rendered_fields(self): + fields = self.serializer.fields + if self.template == 'import': + return [v for k, v in fields.items() if not v.read_only and k != "org_id" and k != 'id'] + elif self.template == 'update': + return [v for k, v in fields.items() if not v.read_only and k != "org_id"] + else: + return [v for k, v in fields.items() if not v.write_only and k != "org_id"] + + @staticmethod + def get_column_titles(render_fields): + return [ + '*{}'.format(field.label) if field.required else str(field.label) + for field in render_fields + ] + + def process_data(self, data): + results = data['results'] if 'results' in data else data + + if isinstance(results, dict): + results = [results] + + if self.template == 'import': + results = [results[0]] if results else results + + else: + # 限制数据数量 + results = results[:10000] + # 会将一些 UUID 字段转化为 string + results = json.loads(json.dumps(results, cls=encoders.JSONEncoder)) + return results + + @staticmethod + def generate_rows(data, render_fields): + for item in data: + row = [] + for field in render_fields: + value = item.get(field.field_name) + value = str(value) if value else '' + row.append(value) + yield row + + @abc.abstractmethod + def initial_writer(self): + raise NotImplementedError + + def write_column_titles(self, column_titles): + self.write_row(column_titles) + + def write_rows(self, rows): + for row in rows: + self.write_row(row) + + @abc.abstractmethod + def write_row(self, row): + raise NotImplementedError + + @abc.abstractmethod + def get_rendered_value(self): + raise NotImplementedError + + def render(self, data, accepted_media_type=None, renderer_context=None): + if data is None: + return bytes() + + if not self._check_validation_data(data): + return self._json_format_response(data) + + try: + renderer_context = renderer_context or {} + request = renderer_context['request'] + response = renderer_context['response'] + view = renderer_context['view'] + self.template = request.query_params.get('template', 'export') + self.serializer = view.get_serializer() + self.set_response_disposition(response) + except Exception as e: + logger.debug(e, exc_info=True) + value = 'The resource not support export!'.encode('utf-8') + return value + + try: + rendered_fields = self.get_rendered_fields() + column_titles = self.get_column_titles(rendered_fields) + data = self.process_data(data) + rows = self.generate_rows(data, rendered_fields) + self.initial_writer() + self.write_column_titles(column_titles) + self.write_rows(rows) + value = self.get_rendered_value() + except Exception as e: + logger.debug(e, exc_info=True) + value = 'Render error! ({})'.format(self.media_type).encode('utf-8') + return value + + return value + diff --git a/apps/common/drf/renders/csv.py b/apps/common/drf/renders/csv.py index 435e3d4a6..ba469a21f 100644 --- a/apps/common/drf/renders/csv.py +++ b/apps/common/drf/renders/csv.py @@ -1,83 +1,30 @@ # ~*~ coding: utf-8 ~*~ # -import unicodecsv import codecs -from datetime import datetime - +import unicodecsv from six import BytesIO -from rest_framework.renderers import BaseRenderer -from rest_framework.utils import encoders, json -from common.utils import get_logger - -logger = get_logger(__file__) +from .base import BaseFileRenderer -class JMSCSVRender(BaseRenderer): - +class CSVFileRenderer(BaseFileRenderer): media_type = 'text/csv' format = 'csv' - @staticmethod - def _get_show_fields(fields, template): - if template == 'import': - return [v for k, v in fields.items() if not v.read_only and k != "org_id" and k != 'id'] - elif template == 'update': - return [v for k, v in fields.items() if not v.read_only and k != "org_id"] - else: - return [v for k, v in fields.items() if not v.write_only and k != "org_id"] + writer = None + buffer = None - @staticmethod - def _gen_table(data, fields): - data = data[:10000] - yield ['*{}'.format(f.label) if f.required else f.label for f in fields] + def initial_writer(self): + csv_buffer = BytesIO() + csv_buffer.write(codecs.BOM_UTF8) + csv_writer = unicodecsv.writer(csv_buffer, encoding='utf-8') + self.buffer = csv_buffer + self.writer = csv_writer - for item in data: - row = [item.get(f.field_name) for f in fields] - yield row - - def set_response_disposition(self, serializer, context): - response = context.get('response') - if response and hasattr(serializer, 'Meta') and \ - hasattr(serializer.Meta, "model"): - model_name = serializer.Meta.model.__name__.lower() - now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = "{}_{}.csv".format(model_name, now) - disposition = 'attachment; filename="{}"'.format(filename) - response['Content-Disposition'] = disposition - - def render(self, data, media_type=None, renderer_context=None): - renderer_context = renderer_context or {} - request = renderer_context['request'] - template = request.query_params.get('template', 'export') - view = renderer_context['view'] - - if isinstance(data, dict): - data = data.get("results", []) - - if template == 'import': - data = [data[0]] if data else data - - data = json.loads(json.dumps(data, cls=encoders.JSONEncoder)) - - try: - serializer = view.get_serializer() - self.set_response_disposition(serializer, renderer_context) - except Exception as e: - logger.debug(e, exc_info=True) - value = 'The resource not support export!'.encode('utf-8') - else: - fields = serializer.fields - show_fields = self._get_show_fields(fields, template) - table = self._gen_table(data, show_fields) - - csv_buffer = BytesIO() - csv_buffer.write(codecs.BOM_UTF8) - csv_writer = unicodecsv.writer(csv_buffer, encoding='utf-8') - for row in table: - csv_writer.writerow(row) - - value = csv_buffer.getvalue() + def write_row(self, row): + self.writer.writerow(row) + def get_rendered_value(self): + value = self.buffer.getvalue() return value diff --git a/apps/common/drf/renders/excel.py b/apps/common/drf/renders/excel.py new file mode 100644 index 000000000..0d1cb8d51 --- /dev/null +++ b/apps/common/drf/renders/excel.py @@ -0,0 +1,28 @@ +from openpyxl import Workbook +from openpyxl.writer.excel import save_virtual_workbook + +from .base import BaseFileRenderer + + +class ExcelFileRenderer(BaseFileRenderer): + media_type = "application/xlsx" + format = "xlsx" + + wb = None + ws = None + row_count = 0 + + def initial_writer(self): + self.wb = Workbook() + self.ws = self.wb.active + + def write_row(self, row): + self.row_count += 1 + column_count = 0 + for cell_value in row: + column_count += 1 + self.ws.cell(row=self.row_count, column=column_count, value=cell_value) + + def get_rendered_value(self): + value = save_virtual_workbook(self.wb) + return value diff --git a/apps/common/fields/form.py b/apps/common/fields/form.py index c4cdc78ad..fd144ec92 100644 --- a/apps/common/fields/form.py +++ b/apps/common/fields/form.py @@ -3,7 +3,7 @@ import json from django import forms -from django.utils import six +import six from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _ from ..utils import signer diff --git a/apps/common/fields/model.py b/apps/common/fields/model.py index 1161944a6..4a4f3525d 100644 --- a/apps/common/fields/model.py +++ b/apps/common/fields/model.py @@ -31,7 +31,7 @@ class JsonMixin: def json_encode(data): return json.dumps(data) - def from_db_value(self, value, expression, connection, context): + def from_db_value(self, value, expression, connection, context=None): if value is None: return value return self.json_decode(value) @@ -54,7 +54,7 @@ class JsonMixin: class JsonTypeMixin(JsonMixin): tp = dict - def from_db_value(self, value, expression, connection, context): + def from_db_value(self, value, expression, connection, context=None): value = super().from_db_value(value, expression, connection, context) if not isinstance(value, self.tp): value = self.tp() @@ -116,7 +116,7 @@ class EncryptMixin: def decrypt_from_signer(self, value): return signer.unsign(value) or '' - def from_db_value(self, value, expression, connection, context): + def from_db_value(self, value, expression, connection, context=None): if value is None: return value value = force_text(value) diff --git a/apps/common/fields/serializer.py b/apps/common/fields/serializer.py index e7a6e7d9c..9cd630650 100644 --- a/apps/common/fields/serializer.py +++ b/apps/common/fields/serializer.py @@ -2,7 +2,7 @@ # from rest_framework import serializers -from django.utils import six +import six __all__ = [ diff --git a/apps/common/utils/common.py b/apps/common/utils/common.py index 382cd2412..25b2e771b 100644 --- a/apps/common/utils/common.py +++ b/apps/common/utils/common.py @@ -41,7 +41,7 @@ def timesince(dt, since='', default="just now"): 3 days, 5 hours. """ - if since is '': + if not since: since = datetime.datetime.utcnow() if since is None: diff --git a/apps/common/utils/inspect.py b/apps/common/utils/inspect.py new file mode 100644 index 000000000..650d3b434 --- /dev/null +++ b/apps/common/utils/inspect.py @@ -0,0 +1,10 @@ +import inspect + + +def copy_function_args(func, locals_dict: dict): + signature = inspect.signature(func) + keys = signature.parameters.keys() + kwargs = {} + for k in keys: + kwargs[k] = locals_dict.get(k) + return kwargs diff --git a/apps/common/utils/lock.py b/apps/common/utils/lock.py new file mode 100644 index 000000000..9041a2578 --- /dev/null +++ b/apps/common/utils/lock.py @@ -0,0 +1,55 @@ +from functools import wraps + +from redis_lock import Lock as RedisLock +from redis import Redis + +from common.utils import get_logger +from common.utils.inspect import copy_function_args +from apps.jumpserver.const import CONFIG + +logger = get_logger(__file__) + + +class AcquireFailed(RuntimeError): + pass + + +class DistributedLock(RedisLock): + def __init__(self, name, blocking=True, expire=60*2, auto_renewal=True): + """ + 使用 redis 构造的分布式锁 + + :param name: + 锁的名字,要全局唯一 + :param blocking: + 该参数只在锁作为装饰器或者 `with` 时有效。 + :param expire: + 锁的过期时间,注意不一定是锁到这个时间就释放了,分两种情况 + 当 `auto_renewal=False` 时,锁会释放 + 当 `auto_renewal=True` 时,如果过期之前程序还没释放锁,我们会延长锁的存活时间。 + 这里的作用是防止程序意外终止没有释放锁,导致死锁。 + """ + self.kwargs_copy = copy_function_args(self.__init__, locals()) + redis = Redis(host=CONFIG.REDIS_HOST, port=CONFIG.REDIS_PORT, password=CONFIG.REDIS_PASSWORD) + super().__init__(redis_client=redis, name=name, expire=expire, auto_renewal=auto_renewal) + self._blocking = blocking + + def __enter__(self): + acquired = self.acquire(blocking=self._blocking) + if self._blocking and not acquired: + raise EnvironmentError("Lock wasn't acquired, but blocking=True") + if not acquired: + raise AcquireFailed + return self + + def __exit__(self, exc_type=None, exc_value=None, traceback=None): + self.release() + + def __call__(self, func): + @wraps(func) + def inner(*args, **kwds): + # 要创建一个新的锁对象 + with self.__class__(**self.kwargs_copy): + return func(*args, **kwds) + + return inner diff --git a/apps/common/validators.py b/apps/common/validators.py index d9b5f74a4..0ce334552 100644 --- a/apps/common/validators.py +++ b/apps/common/validators.py @@ -14,9 +14,9 @@ alphanumeric = RegexValidator(r'^[0-9a-zA-Z_@\-\.]*$', _('Special char not allow class ProjectUniqueValidator(UniqueTogetherValidator): - def __call__(self, attrs): + def __call__(self, attrs, serializer): try: - super().__call__(attrs) + super().__call__(attrs, serializer) except ValidationError as e: errors = {} for field in self.fields: diff --git a/apps/jumpserver/api.py b/apps/jumpserver/api.py index 026a90b9a..b74099fa3 100644 --- a/apps/jumpserver/api.py +++ b/apps/jumpserver/api.py @@ -2,13 +2,14 @@ from django.core.cache import cache from django.utils import timezone from django.utils.timesince import timesince from django.db.models import Count, Max -from django.http.response import JsonResponse +from django.http.response import JsonResponse, HttpResponse from rest_framework.views import APIView from collections import Counter from users.models import User from assets.models import Asset from terminal.models import Session +from terminal.utils import ComponentsPrometheusMetricsUtil from orgs.utils import current_org from common.permissions import IsOrgAdmin, IsOrgAuditor from common.utils import lazyproperty @@ -305,3 +306,11 @@ class IndexApi(TotalCountMixin, DatesLoginMetricMixin, APIView): return JsonResponse(data, status=200) +class PrometheusMetricsApi(APIView): + permission_classes = () + + def get(self, request, *args, **kwargs): + util = ComponentsPrometheusMetricsUtil() + metrics_text = util.get_prometheus_metrics_text() + return HttpResponse(metrics_text, content_type='text/plain; version=0.0.4; charset=utf-8') + diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index e23ddc23c..4d8b7c0ce 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -16,7 +16,7 @@ import json import yaml from importlib import import_module from django.urls import reverse_lazy -from django.contrib.staticfiles.templatetags.staticfiles import static +from django.templatetags.static import static from urllib.parse import urljoin, urlparse from django.utils.translation import ugettext_lazy as _ diff --git a/apps/jumpserver/context_processor.py b/apps/jumpserver/context_processor.py index 0a49b957d..cf0ea559d 100644 --- a/apps/jumpserver/context_processor.py +++ b/apps/jumpserver/context_processor.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -from django.contrib.staticfiles.templatetags.staticfiles import static +from django.templatetags.static import static from django.conf import settings from django.utils.translation import gettext_lazy as _ diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 47b3d7a04..2a6291751 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -64,6 +64,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.forms', ] diff --git a/apps/jumpserver/settings/libs.py b/apps/jumpserver/settings/libs.py index 9e4b56e21..8a8df7ca4 100644 --- a/apps/jumpserver/settings/libs.py +++ b/apps/jumpserver/settings/libs.py @@ -11,14 +11,17 @@ REST_FRAMEWORK = { ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', - 'rest_framework.renderers.BrowsableAPIRenderer', - 'common.drf.renders.JMSCSVRender', + # 'rest_framework.renderers.BrowsableAPIRenderer', + 'common.drf.renders.CSVFileRenderer', + 'common.drf.renders.ExcelFileRenderer', + ), 'DEFAULT_PARSER_CLASSES': ( 'rest_framework.parsers.JSONParser', 'rest_framework.parsers.FormParser', 'rest_framework.parsers.MultiPartParser', - 'common.drf.parsers.JMSCSVParser', + 'common.drf.parsers.CSVFileParser', + 'common.drf.parsers.ExcelFileParser', 'rest_framework.parsers.FileUploadParser', ), 'DEFAULT_AUTHENTICATION_CLASSES': ( @@ -61,10 +64,10 @@ SWAGGER_SETTINGS = { # Captcha settings, more see https://django-simple-captcha.readthedocs.io/en/latest/advanced.html -CAPTCHA_IMAGE_SIZE = (80, 33) +CAPTCHA_IMAGE_SIZE = (140, 34) CAPTCHA_FOREGROUND_COLOR = '#001100' CAPTCHA_NOISE_FUNCTIONS = ('captcha.helpers.noise_dots',) -CAPTCHA_TEST_MODE = CONFIG.CAPTCHA_TEST_MODE +CAPTCHA_CHALLENGE_FUNCT = 'captcha.helpers.math_challenge' # Django bootstrap3 setting, more see http://django-bootstrap3.readthedocs.io/en/latest/settings.html BOOTSTRAP3 = { diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 624212e6f..d03c20ba3 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -23,6 +23,7 @@ api_v1 = [ path('common/', include('common.urls.api_urls', namespace='api-common')), path('applications/', include('applications.urls.api_urls', namespace='api-applications')), path('tickets/', include('tickets.urls.api_urls', namespace='api-tickets')), + path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()) ] api_v2 = [ @@ -30,7 +31,6 @@ api_v2 = [ path('users/', include('users.urls.api_urls_v2', namespace='api-users-v2')), ] - app_view_patterns = [ path('auth/', include('authentication.urls.view_urls'), name='auth'), path('ops/', include('ops.urls.view_urls'), name='ops'), @@ -63,7 +63,7 @@ urlpatterns = [ # External apps url path('core/auth/captcha/', include('captcha.urls')), path('core/', include(app_view_patterns)), - path('ui/', views.UIView.as_view()) + path('ui/', views.UIView.as_view()), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 801d192a2..0d5b71dab 100644 Binary files a/apps/locale/zh/LC_MESSAGES/django.mo and b/apps/locale/zh/LC_MESSAGES/django.mo differ diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index cefa4bf2a..78bd911f3 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-11-17 17:24+0800\n" +"POT-Creation-Date: 2020-12-10 21:21+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler <ibuler@qq.com>\n" "Language-Team: JumpServer team<ibuler@qq.com>\n" @@ -17,31 +17,32 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: applications/const.py:53 applications/models/application.py:34 +#: applications/const.py:53 applications/models/application.py:33 msgid "Custom" msgstr "自定义" -#: applications/models/application.py:61 applications/models/database_app.py:29 +#: applications/models/application.py:60 applications/models/database_app.py:29 #: applications/serializers/database_app.py:16 #: applications/serializers/remote_app.py:69 #: users/templates/users/user_granted_database_app.html:37 msgid "Database" msgstr "数据库" -#: applications/models/application.py:62 +#: applications/models/application.py:61 msgid "Remote app" msgstr "远程应用" -#: applications/models/application.py:122 +#: applications/models/application.py:121 #: applications/models/database_app.py:18 applications/models/k8s_app.py:11 #: applications/models/remote_app.py:21 assets/models/asset.py:149 #: assets/models/base.py:234 assets/models/cluster.py:18 #: assets/models/cmd_filter.py:21 assets/models/domain.py:21 #: assets/models/group.py:20 assets/models/label.py:18 ops/mixin.py:24 #: orgs/models.py:23 perms/models/base.py:48 settings/models.py:27 -#: terminal/models.py:28 terminal/models.py:372 terminal/models.py:404 -#: terminal/models.py:441 users/forms/profile.py:20 users/models/group.py:15 -#: users/models/user.py:505 users/templates/users/_select_user_modal.html:13 +#: terminal/models/storage.py:21 terminal/models/storage.py:58 +#: terminal/models/task.py:16 terminal/models/terminal.py:131 +#: users/forms/profile.py:20 users/models/group.py:15 users/models/user.py:495 +#: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 #: users/templates/users/user_database_app_permission.html:36 @@ -58,12 +59,12 @@ msgstr "远程应用" msgid "Name" msgstr "名称" -#: applications/models/application.py:123 assets/models/asset.py:198 +#: applications/models/application.py:122 assets/models/asset.py:198 #: assets/models/domain.py:27 assets/models/domain.py:54 msgid "Domain" msgstr "网域" -#: applications/models/application.py:124 +#: applications/models/application.py:123 #: applications/serializers/application.py:16 assets/models/label.py:21 #: perms/models/application_permission.py:19 #: perms/serializers/application/permission.py:16 @@ -71,20 +72,21 @@ msgstr "网域" msgid "Category" msgstr "分类" -#: applications/models/application.py:125 +#: applications/models/application.py:124 #: applications/models/database_app.py:22 applications/models/k8s_app.py:14 #: applications/serializers/application.py:17 assets/models/cmd_filter.py:52 #: perms/models/application_permission.py:20 #: perms/serializers/application/permission.py:17 -#: perms/serializers/application/user_permission.py:34 terminal/models.py:406 -#: terminal/models.py:443 tickets/models/ticket.py:40 +#: perms/serializers/application/user_permission.py:34 +#: terminal/models/storage.py:23 terminal/models/storage.py:60 +#: tickets/models/ticket.py:40 #: users/templates/users/user_granted_database_app.html:35 msgid "Type" msgstr "类型" # msgid "Date created" # msgstr "创建日期" -#: applications/models/application.py:128 +#: applications/models/application.py:127 #: applications/models/database_app.py:33 applications/models/k8s_app.py:18 #: applications/models/remote_app.py:45 assets/models/asset.py:154 #: assets/models/asset.py:230 assets/models/base.py:239 @@ -92,17 +94,17 @@ msgstr "类型" #: assets/models/cmd_filter.py:57 assets/models/domain.py:22 #: assets/models/domain.py:55 assets/models/group.py:23 #: assets/models/label.py:23 ops/models/adhoc.py:37 orgs/models.py:26 -#: perms/models/base.py:56 settings/models.py:32 terminal/models.py:38 -#: terminal/models.py:411 terminal/models.py:448 tickets/models/ticket.py:43 -#: users/models/group.py:16 users/models/user.py:538 -#: users/templates/users/user_detail.html:115 +#: perms/models/base.py:56 settings/models.py:32 terminal/models/storage.py:28 +#: terminal/models/storage.py:65 terminal/models/terminal.py:142 +#: tickets/models/ticket.py:43 users/models/group.py:16 +#: users/models/user.py:528 users/templates/users/user_detail.html:115 #: users/templates/users/user_granted_database_app.html:38 #: users/templates/users/user_granted_remote_app.html:37 #: users/templates/users/user_group_detail.html:62 #: users/templates/users/user_group_list.html:16 #: users/templates/users/user_profile.html:138 -#: xpack/plugins/change_auth_plan/models.py:77 xpack/plugins/cloud/models.py:54 -#: xpack/plugins/cloud/models.py:149 xpack/plugins/gathered_user/models.py:26 +#: xpack/plugins/change_auth_plan/models.py:77 xpack/plugins/cloud/models.py:55 +#: xpack/plugins/cloud/models.py:150 xpack/plugins/gathered_user/models.py:26 msgid "Comment" msgstr "备注" @@ -114,9 +116,9 @@ msgstr "主机" #: applications/models/database_app.py:27 #: applications/serializers/database_app.py:14 -#: applications/serializers/database_app.py:21 -#: applications/serializers/database_app.py:25 -#: applications/serializers/database_app.py:29 +#: applications/serializers/database_app.py:20 +#: applications/serializers/database_app.py:24 +#: applications/serializers/database_app.py:28 #: applications/serializers/remote_app.py:68 assets/models/asset.py:195 #: assets/models/domain.py:52 msgid "Port" @@ -154,12 +156,12 @@ msgstr "Kubernetes应用" #: audits/models.py:38 perms/forms/asset_permission.py:89 #: perms/models/asset_permission.py:92 templates/index.html:82 #: terminal/backends/command/models.py:19 -#: terminal/backends/command/serializers.py:13 terminal/models.py:192 +#: terminal/backends/command/serializers.py:13 terminal/models/session.py:39 #: users/templates/users/user_asset_permission.html:40 #: users/templates/users/user_asset_permission.html:70 #: users/templates/users/user_granted_remote_app.html:36 #: xpack/plugins/change_auth_plan/models.py:282 -#: xpack/plugins/cloud/models.py:278 +#: xpack/plugins/cloud/models.py:279 msgid "Asset" msgstr "资产" @@ -182,10 +184,10 @@ msgstr "参数" #: assets/models/cmd_filter.py:26 assets/models/cmd_filter.py:60 #: assets/models/group.py:21 common/db/models.py:67 common/mixins/models.py:49 #: orgs/models.py:24 orgs/models.py:400 perms/models/base.py:54 -#: users/models/user.py:546 users/serializers/group.py:35 +#: users/models/user.py:536 users/serializers/group.py:35 #: users/templates/users/user_detail.html:97 -#: xpack/plugins/change_auth_plan/models.py:81 xpack/plugins/cloud/models.py:57 -#: xpack/plugins/cloud/models.py:155 xpack/plugins/gathered_user/models.py:30 +#: xpack/plugins/change_auth_plan/models.py:81 xpack/plugins/cloud/models.py:58 +#: xpack/plugins/cloud/models.py:156 xpack/plugins/gathered_user/models.py:30 msgid "Created by" msgstr "创建者" @@ -198,7 +200,7 @@ msgstr "创建者" #: common/mixins/models.py:50 ops/models/adhoc.py:38 ops/models/command.py:27 #: orgs/models.py:25 orgs/models.py:398 perms/models/base.py:55 #: users/models/group.py:18 users/templates/users/user_group_detail.html:58 -#: xpack/plugins/cloud/models.py:60 xpack/plugins/cloud/models.py:158 +#: xpack/plugins/cloud/models.py:61 xpack/plugins/cloud/models.py:159 msgid "Date created" msgstr "创建日期" @@ -211,7 +213,7 @@ msgstr "创建日期" msgid "RemoteApp" msgstr "远程应用" -#: applications/serializers/database_app.py:50 +#: applications/serializers/database_app.py:49 #: applications/serializers/k8s_app.py:17 #: applications/serializers/remote_app.py:162 audits/serializers.py:26 msgid "Type for display" @@ -237,7 +239,7 @@ msgstr "目标URL" #: authentication/forms.py:11 #: authentication/templates/authentication/login.html:21 #: authentication/templates/authentication/xpack_login.html:101 -#: ops/models/adhoc.py:148 users/forms/profile.py:19 users/models/user.py:503 +#: ops/models/adhoc.py:148 users/forms/profile.py:19 users/models/user.py:493 #: users/templates/users/_select_user_modal.html:14 #: users/templates/users/user_detail.html:53 #: users/templates/users/user_list.html:15 @@ -251,7 +253,8 @@ msgstr "用户名" #: applications/serializers/remote_app.py:71 #: applications/serializers/remote_app.py:79 #: applications/serializers/remote_app.py:86 assets/models/base.py:236 -#: assets/serializers/asset_user.py:71 authentication/forms.py:13 +#: assets/serializers/asset_user.py:71 audits/signals_handler.py:42 +#: authentication/forms.py:13 #: authentication/templates/authentication/login.html:29 #: authentication/templates/authentication/xpack_login.html:109 #: users/forms/user.py:22 users/forms/user.py:193 @@ -295,11 +298,15 @@ msgstr "删除失败,存在关联资产" msgid "Number required" msgstr "需要为数字" -#: assets/api/node.py:58 +#: assets/api/node.py:67 msgid "You can't update the root node name" msgstr "不能修改根节点名称" -#: assets/api/node.py:65 +#: assets/api/node.py:74 +msgid "You can't delete the root node ({})" +msgstr "不能删除根节点 ({})" + +#: assets/api/node.py:77 msgid "Deletion failed and the node contains children or assets" msgstr "删除失败,节点包含子节点或资产" @@ -334,7 +341,7 @@ msgstr "系统平台" #: assets/models/asset.py:191 assets/serializers/asset_user.py:45 #: assets/serializers/gathered_user.py:20 settings/serializers/settings.py:51 -#: tickets/api/request_asset_perm.py:63 +#: tickets/api/request_asset_perm.py:67 #: tickets/serializers/request_asset_perm.py:25 #: users/templates/users/_granted_assets.html:25 #: users/templates/users/user_asset_permission.html:157 @@ -360,13 +367,13 @@ msgstr "节点" #: assets/models/asset.py:200 assets/models/cmd_filter.py:22 #: assets/models/domain.py:56 assets/models/label.py:22 -#: authentication/models.py:48 +#: authentication/models.py:46 msgid "Is active" msgstr "激活" #: assets/models/asset.py:203 assets/models/cluster.py:19 #: assets/models/user.py:66 templates/_nav.html:44 -#: xpack/plugins/cloud/models.py:142 xpack/plugins/cloud/serializers.py:84 +#: xpack/plugins/cloud/models.py:143 xpack/plugins/cloud/serializers.py:115 msgid "Admin user" msgstr "管理用户" @@ -480,7 +487,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:524 +#: assets/models/cluster.py:22 users/models/user.py:514 #: users/templates/users/user_detail.html:62 msgid "Phone" msgstr "手机" @@ -506,7 +513,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:665 +#: users/models/user.py:655 msgid "System" msgstr "系统" @@ -523,7 +530,7 @@ msgid "Regex" msgstr "正则表达式" #: assets/models/cmd_filter.py:41 ops/models/command.py:23 -#: terminal/backends/command/serializers.py:15 terminal/models.py:201 +#: terminal/backends/command/serializers.py:15 terminal/models/session.py:48 msgid "Command" msgstr "命令" @@ -607,17 +614,17 @@ msgid "Default asset group" msgstr "默认资产组" #: assets/models/label.py:15 audits/models.py:36 audits/models.py:56 -#: audits/models.py:69 audits/serializers.py:80 authentication/models.py:46 -#: authentication/models.py:90 orgs/models.py:18 orgs/models.py:396 +#: audits/models.py:69 audits/serializers.py:81 authentication/models.py:44 +#: authentication/models.py:88 orgs/models.py:18 orgs/models.py:396 #: perms/forms/asset_permission.py:83 perms/forms/database_app_permission.py:38 #: perms/forms/remote_app_permission.py:40 perms/models/asset_permission.py:169 #: perms/models/base.py:49 templates/index.html:78 #: terminal/backends/command/models.py:18 -#: terminal/backends/command/serializers.py:12 terminal/models.py:190 +#: terminal/backends/command/serializers.py:12 terminal/models/session.py:37 #: tickets/models/ticket.py:30 tickets/models/ticket.py:136 #: tickets/serializers/request_asset_perm.py:66 #: tickets/serializers/ticket.py:31 users/forms/group.py:15 -#: users/models/user.py:159 users/models/user.py:653 +#: users/models/user.py:159 users/models/user.py:643 #: users/serializers/group.py:20 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -631,7 +638,7 @@ msgstr "默认资产组" msgid "User" msgstr "用户" -#: assets/models/label.py:19 assets/models/node.py:398 settings/models.py:28 +#: assets/models/label.py:19 assets/models/node.py:401 settings/models.py:28 msgid "Value" msgstr "值" @@ -643,24 +650,24 @@ msgstr "新节点" msgid "empty" msgstr "空" -#: assets/models/node.py:397 perms/models/asset_permission.py:144 +#: assets/models/node.py:400 perms/models/asset_permission.py:144 msgid "Key" msgstr "键" -#: assets/models/node.py:399 +#: assets/models/node.py:402 msgid "Full value" msgstr "全称" -#: assets/models/node.py:402 perms/models/asset_permission.py:148 +#: assets/models/node.py:405 perms/models/asset_permission.py:148 msgid "Parent key" msgstr "ssh私钥" -#: assets/models/node.py:411 assets/serializers/system_user.py:190 +#: assets/models/node.py:414 assets/serializers/system_user.py:190 #: perms/forms/asset_permission.py:92 perms/forms/asset_permission.py:99 #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 -#: xpack/plugins/cloud/models.py:138 xpack/plugins/cloud/serializers.py:85 +#: xpack/plugins/cloud/models.py:139 xpack/plugins/cloud/serializers.py:116 msgid "Node" msgstr "节点" @@ -711,7 +718,7 @@ msgstr "登录模式" msgid "SFTP Root" msgstr "SFTP根路径" -#: assets/models/user.py:110 authentication/models.py:88 +#: assets/models/user.py:110 authentication/models.py:86 msgid "Token" msgstr "" @@ -731,8 +738,8 @@ msgstr "用户组" #: perms/models/k8s_app_permission.py:22 #: perms/models/remote_app_permission.py:16 templates/_nav.html:45 #: terminal/backends/command/models.py:20 -#: terminal/backends/command/serializers.py:14 terminal/models.py:194 -#: tickets/api/request_asset_perm.py:64 +#: terminal/backends/command/serializers.py:14 terminal/models/session.py:41 +#: tickets/api/request_asset_perm.py:68 #: tickets/serializers/request_asset_perm.py:27 #: users/templates/users/_granted_assets.html:27 #: users/templates/users/user_asset_permission.html:42 @@ -782,15 +789,15 @@ msgstr "管理用户名称" msgid "Nodes name" msgstr "节点名称" -#: assets/serializers/asset.py:110 +#: assets/serializers/asset.py:107 msgid "Hardware info" msgstr "硬件信息" -#: assets/serializers/asset.py:111 orgs/mixins/serializers.py:26 +#: assets/serializers/asset.py:108 orgs/mixins/serializers.py:26 msgid "Org name" msgstr "组织名称" -#: assets/serializers/asset.py:165 assets/serializers/asset.py:196 +#: assets/serializers/asset.py:162 assets/serializers/asset.py:193 msgid "Connectivity" msgstr "连接" @@ -805,14 +812,14 @@ msgid "Backend" msgstr "后端" #: assets/serializers/asset_user.py:75 users/forms/profile.py:148 -#: users/models/user.py:535 users/templates/users/user_password_update.html:48 +#: users/models/user.py:525 users/templates/users/user_password_update.html:48 #: users/templates/users/user_profile.html:69 #: users/templates/users/user_profile_update.html:46 #: users/templates/users/user_pubkey_update.html:46 msgid "Public key" msgstr "SSH公钥" -#: assets/serializers/asset_user.py:79 users/models/user.py:532 +#: assets/serializers/asset_user.py:79 users/models/user.py:522 msgid "Private key" msgstr "ssh私钥" @@ -932,25 +939,30 @@ msgstr "更新节点资产硬件信息: {}" msgid "Gather assets users" msgstr "收集资产上的用户" +#: assets/tasks/nodes_amount.py:21 +msgid "" +"The task of self-checking is already running and cannot be started repeatedly" +msgstr "自检程序已经在运行,不能重复启动" + #: assets/tasks/push_system_user.py:184 #: assets/tasks/system_user_connectivity.py:89 msgid "System user is dynamic: {}" msgstr "系统用户是动态的: {}" -#: assets/tasks/push_system_user.py:215 +#: assets/tasks/push_system_user.py:224 msgid "Start push system user for platform: [{}]" msgstr "推送系统用户到平台: [{}]" -#: assets/tasks/push_system_user.py:216 +#: assets/tasks/push_system_user.py:225 #: assets/tasks/system_user_connectivity.py:81 msgid "Hosts count: {}" msgstr "主机数量: {}" -#: assets/tasks/push_system_user.py:235 assets/tasks/push_system_user.py:253 +#: assets/tasks/push_system_user.py:264 assets/tasks/push_system_user.py:290 msgid "Push system users to assets: {}" msgstr "推送系统用户到入资产: {}" -#: assets/tasks/push_system_user.py:243 +#: assets/tasks/push_system_user.py:276 msgid "Push system users to asset: {}({}) => {}" msgstr "推送系统用户到入资产: {}({}) => {}" @@ -1030,7 +1042,7 @@ msgid "Symlink" msgstr "建立软链接" #: audits/models.py:37 audits/models.py:60 audits/models.py:71 -#: terminal/models.py:197 +#: terminal/models/session.py:44 msgid "Remote addr" msgstr "远端地址" @@ -1048,7 +1060,7 @@ msgid "Success" msgstr "成功" #: audits/models.py:43 ops/models/command.py:28 perms/models/base.py:52 -#: terminal/models.py:204 tickets/serializers/request_asset_perm.py:29 +#: terminal/models/session.py:51 tickets/serializers/request_asset_perm.py:29 #: xpack/plugins/change_auth_plan/models.py:177 #: xpack/plugins/change_auth_plan/models.py:307 #: xpack/plugins/gathered_user/models.py:76 @@ -1105,7 +1117,7 @@ msgstr "启用" msgid "-" msgstr "" -#: audits/models.py:96 xpack/plugins/cloud/models.py:213 +#: audits/models.py:96 xpack/plugins/cloud/models.py:214 msgid "Failed" msgstr "失败" @@ -1121,27 +1133,27 @@ msgstr "登录IP" msgid "Login city" msgstr "登录城市" -#: audits/models.py:103 audits/serializers.py:37 +#: audits/models.py:103 audits/serializers.py:38 msgid "User agent" msgstr "用户代理" #: audits/models.py:104 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:52 users/models/user.py:527 -#: users/serializers/user.py:228 users/templates/users/user_detail.html:77 +#: users/forms/profile.py:52 users/models/user.py:517 +#: users/serializers/user.py:232 users/templates/users/user_detail.html:77 #: users/templates/users/user_profile.html:87 msgid "MFA" msgstr "多因子认证" #: audits/models.py:105 xpack/plugins/change_auth_plan/models.py:303 -#: xpack/plugins/cloud/models.py:226 +#: xpack/plugins/cloud/models.py:227 msgid "Reason" msgstr "原因" #: audits/models.py:106 tickets/serializers/request_asset_perm.py:64 -#: tickets/serializers/ticket.py:29 xpack/plugins/cloud/models.py:223 -#: xpack/plugins/cloud/models.py:281 +#: tickets/serializers/ticket.py:29 xpack/plugins/cloud/models.py:224 +#: xpack/plugins/cloud/models.py:282 msgid "Status" msgstr "状态" @@ -1149,6 +1161,10 @@ msgstr "状态" msgid "Date login" msgstr "登录日期" +#: audits/models.py:108 +msgid "Login backend" +msgstr "登录引擎" + #: audits/serializers.py:15 msgid "Operate for display" msgstr "操作(显示名称)" @@ -1161,32 +1177,40 @@ msgstr "状态(显示名称)" msgid "MFA for display" msgstr "多因子认证状态(显示名称)" -#: audits/serializers.py:65 audits/serializers.py:77 ops/models/adhoc.py:244 +#: audits/serializers.py:66 audits/serializers.py:78 ops/models/adhoc.py:244 #: terminal/serializers/session.py:34 msgid "Is success" msgstr "是否成功" -#: audits/serializers.py:76 ops/models/command.py:24 -#: xpack/plugins/cloud/models.py:221 +#: audits/serializers.py:77 ops/models/command.py:24 +#: xpack/plugins/cloud/models.py:222 msgid "Result" msgstr "结果" -#: audits/serializers.py:78 +#: audits/serializers.py:79 msgid "Hosts" msgstr "主机" -#: audits/serializers.py:79 +#: audits/serializers.py:80 msgid "Run as" msgstr "运行用户" -#: audits/serializers.py:81 +#: audits/serializers.py:82 msgid "Run as for display" msgstr "运行用户(显示名称)" -#: audits/serializers.py:82 +#: audits/serializers.py:83 msgid "User for display" msgstr "用户(显示名称)" +#: audits/signals_handler.py:38 +msgid "SSH Key" +msgstr "SSH 密钥" + +#: audits/signals_handler.py:43 +msgid "SSO" +msgstr "" + #: authentication/api/mfa.py:60 msgid "Code is invalid" msgstr "Code无效" @@ -1327,14 +1351,18 @@ msgstr "SSO 认证关闭了" msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/forms.py:26 authentication/forms.py:34 +#: authentication/errors.py:227 authentication/views/login.py:262 +msgid "Your password has expired, please reset before logging in" +msgstr "您的密码已过期,先修改再登录" + +#: authentication/forms.py:26 authentication/forms.py:38 #: authentication/templates/authentication/login.html:39 #: authentication/templates/authentication/xpack_login.html:119 #: users/forms/user.py:199 msgid "MFA code" msgstr "多因子认证验证码" -#: authentication/models.py:22 +#: authentication/models.py:20 #: authentication/templates/authentication/_access_key_modal.html:32 #: perms/models/base.py:51 users/templates/users/_select_user_modal.html:18 #: users/templates/users/user_detail.html:132 @@ -1342,24 +1370,24 @@ msgstr "多因子认证验证码" msgid "Active" msgstr "激活中" -#: authentication/models.py:42 +#: authentication/models.py:40 msgid "Private Token" msgstr "SSH密钥" -#: authentication/models.py:47 users/templates/users/user_detail.html:258 +#: authentication/models.py:45 users/templates/users/user_detail.html:258 msgid "Reviewers" msgstr "审批人" -#: authentication/models.py:56 tickets/models/ticket.py:23 +#: authentication/models.py:54 tickets/models/ticket.py:23 #: users/templates/users/user_detail.html:250 msgid "Login confirm" msgstr "登录复核" -#: authentication/models.py:66 +#: authentication/models.py:64 msgid "City" msgstr "城市" -#: authentication/models.py:89 +#: authentication/models.py:87 msgid "Expired" msgstr "过期时间" @@ -1389,7 +1417,7 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: users/models/user.py:425 users/serializers/user.py:225 +#: users/models/user.py:421 users/serializers/user.py:229 #: users/templates/users/user_profile.html:94 #: users/templates/users/user_profile.html:163 #: users/templates/users/user_profile.html:166 @@ -1398,7 +1426,7 @@ msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:426 users/serializers/user.py:226 +#: users/models/user.py:422 users/serializers/user.py:230 #: users/templates/users/user_profile.html:92 #: users/templates/users/user_profile.html:170 msgid "Enable" @@ -1414,6 +1442,15 @@ msgstr "删除成功" msgid "Close" msgstr "关闭" +#: authentication/templates/authentication/_captcha_field.html:8 +msgid "Play CAPTCHA as audio file" +msgstr "语言播放验证码" + +#: authentication/templates/authentication/_captcha_field.html:15 +#: users/forms/profile.py:90 +msgid "Captcha" +msgstr "验证码" + #: authentication/templates/authentication/_mfa_confirm_modal.html:5 msgid "MFA confirm" msgstr "多因子认证校验" @@ -1538,7 +1575,7 @@ msgstr "退出登录成功" msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" -#: authentication/views/login.py:246 +#: authentication/views/login.py:246 authentication/views/login.py:261 msgid "Please change your password" msgstr "请修改密码" @@ -1556,10 +1593,9 @@ msgstr "%(name)s 更新成功" msgid "Updated by" msgstr "更新人" -#: common/drf/parsers/csv.py:22 -#, python-format -msgid "The max size of CSV is %d bytes" -msgstr "CSV 文件最大为 %d 字节" +#: common/drf/parsers/base.py:16 +msgid "The file content overflowed (The maximum length `{}` bytes)" +msgstr "文件内容益处 (最大长度 `{}` 字节)" #: common/exceptions.py:15 #, python-format @@ -1838,7 +1874,7 @@ msgid "The current organization cannot be deleted" msgstr "当前组织不能被删除" #: orgs/mixins/models.py:56 orgs/mixins/serializers.py:25 orgs/models.py:41 -#: orgs/models.py:395 orgs/serializers.py:80 orgs/serializers.py:91 +#: orgs/models.py:395 orgs/serializers.py:79 msgid "Organization" msgstr "组织" @@ -1850,7 +1886,7 @@ msgstr "组织管理员" msgid "Organization auditor" msgstr "组织审计员" -#: orgs/models.py:397 users/forms/user.py:27 users/models/user.py:515 +#: orgs/models.py:397 users/forms/user.py:27 users/models/user.py:505 #: users/templates/users/_select_user_modal.html:15 #: users/templates/users/user_detail.html:73 #: users/templates/users/user_list.html:16 @@ -1883,7 +1919,7 @@ msgstr "提示:RDP 协议不支持单独控制上传或下载文件" #: perms/forms/asset_permission.py:86 perms/forms/database_app_permission.py:41 #: perms/forms/remote_app_permission.py:43 perms/models/base.py:50 #: templates/_nav.html:21 users/forms/user.py:168 users/models/group.py:31 -#: users/models/user.py:511 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:501 users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_database_app_permission.html:38 @@ -1957,7 +1993,7 @@ msgid "Asset permission" msgstr "资产授权" #: perms/models/base.py:53 tickets/serializers/request_asset_perm.py:31 -#: users/models/user.py:543 users/templates/users/user_detail.html:93 +#: users/models/user.py:533 users/templates/users/user_detail.html:93 #: users/templates/users/user_profile.html:120 msgid "Date expired" msgstr "失效日期" @@ -1982,7 +2018,7 @@ msgid "" "permission type. ({})" msgstr "应用列表中包含与授权类型不同的应用。({})" -#: perms/serializers/asset/permission.py:58 users/serializers/user.py:76 +#: perms/serializers/asset/permission.py:58 users/serializers/user.py:80 msgid "Is expired" msgstr "是否过期" @@ -1991,7 +2027,7 @@ msgstr "是否过期" #: perms/serializers/database_app_permission.py:62 #: perms/serializers/k8s_app_permission.py:41 #: perms/serializers/k8s_app_permission.py:60 -#: perms/serializers/remote_app_permission.py:36 users/serializers/user.py:75 +#: perms/serializers/remote_app_permission.py:36 users/serializers/user.py:79 msgid "Is valid" msgstr "账户是否有效" @@ -2040,7 +2076,7 @@ msgstr "远程应用数量" msgid "Favorite" msgstr "收藏夹" -#: perms/utils/asset/user_permission.py:526 +#: perms/utils/asset/user_permission.py:522 msgid "Please wait while your data is being initialized" msgstr "数据正在初始化,请稍等" @@ -2479,14 +2515,6 @@ msgstr "显示第 _START_ 至 _END_ 项结果; 总共 _TOTAL_ 项" msgid "Home page" msgstr "首页" -#: templates/captcha/image.html:3 -msgid "Play CAPTCHA as audio file" -msgstr "语言播放验证码" - -#: templates/captcha/text_field.html:4 users/forms/profile.py:90 -msgid "Captcha" -msgstr "验证码" - #: templates/delete_confirm.html:6 msgid "Confirm delete" msgstr "确认删除" @@ -2716,70 +2744,105 @@ msgstr "风险等级(显示名称)" msgid "Timestamp" msgstr "时间戳" +#: terminal/const.py:117 +msgid "Critical" +msgstr "严重" + +#: terminal/const.py:118 +msgid "High" +msgstr "较高" + +#: terminal/const.py:119 users/templates/users/reset_password.html:50 +#: users/templates/users/user_create.html:35 +#: users/templates/users/user_password_update.html:104 +#: users/templates/users/user_update.html:57 +msgid "Normal" +msgstr "正常" + #: terminal/exceptions.py:8 msgid "Bulk create not support" msgstr "不支持批量创建" -#: terminal/models.py:29 -msgid "Remote Address" -msgstr "远端地址" - -#: terminal/models.py:30 -msgid "SSH Port" -msgstr "SSH端口" - -#: terminal/models.py:31 -msgid "HTTP Port" -msgstr "HTTP端口" - -#: terminal/models.py:32 -msgid "Command storage" -msgstr "命令存储" - -#: terminal/models.py:33 -msgid "Replay storage" -msgstr "录像存储" - -#: terminal/models.py:156 -msgid "Session Online" -msgstr "在线会话" - -#: terminal/models.py:157 -msgid "CPU Usage" -msgstr "CPU使用" - -#: terminal/models.py:158 -msgid "Memory Used" -msgstr "内存使用" - -#: terminal/models.py:159 -msgid "Connections" -msgstr "连接数" - -#: terminal/models.py:160 -msgid "Threads" -msgstr "线程数" - -#: terminal/models.py:161 -msgid "Boot Time" -msgstr "运行时间" - -#: terminal/models.py:196 +#: terminal/models/session.py:43 msgid "Login from" msgstr "登录来源" -#: terminal/models.py:200 +#: terminal/models/session.py:47 msgid "Replay" msgstr "回放" -#: terminal/models.py:205 +#: terminal/models/session.py:52 msgid "Date end" msgstr "结束日期" -#: terminal/models.py:373 +#: terminal/models/status.py:13 +msgid "Session Online" +msgstr "在线会话" + +#: terminal/models/status.py:14 +msgid "CPU Usage" +msgstr "CPU使用" + +#: terminal/models/status.py:15 +msgid "Memory Used" +msgstr "内存使用" + +#: terminal/models/status.py:16 +msgid "Connections" +msgstr "连接数" + +#: terminal/models/status.py:17 +msgid "Threads" +msgstr "线程数" + +#: terminal/models/status.py:18 +msgid "Boot Time" +msgstr "运行时间" + +#: terminal/models/task.py:17 msgid "Args" msgstr "参数" +#: terminal/models/terminal.py:132 +msgid "type" +msgstr "类型" + +#: terminal/models/terminal.py:133 +msgid "Remote Address" +msgstr "远端地址" + +#: terminal/models/terminal.py:134 +msgid "SSH Port" +msgstr "SSH端口" + +#: terminal/models/terminal.py:135 +msgid "HTTP Port" +msgstr "HTTP端口" + +#: terminal/models/terminal.py:136 +msgid "Command storage" +msgstr "命令存储" + +#: terminal/models/terminal.py:137 +msgid "Replay storage" +msgstr "录像存储" + +#: terminal/serializers/components.py:9 +msgid "System cpu load (1 minutes)" +msgstr "系统CPU负载 (1分钟)" + +#: terminal/serializers/components.py:12 +msgid "System memory used percent" +msgstr "系统内存使用百分比" + +#: terminal/serializers/components.py:15 +msgid "System disk used percent" +msgstr "系统磁盘使用百分比" + +#: terminal/serializers/components.py:19 +msgid "Session active count" +msgstr "活跃会话数量" + #: terminal/serializers/session.py:30 msgid "User ID" msgstr "用户 ID" @@ -2804,18 +2867,18 @@ msgstr "是否可重放" msgid "Can join" msgstr "是否可加入" -#: terminal/serializers/terminal.py:38 terminal/serializers/terminal.py:46 +#: terminal/serializers/terminal.py:44 terminal/serializers/terminal.py:52 msgid "Not found" msgstr "没有发现" -#: terminal/utils.py:73 +#: terminal/utils.py:74 #, python-format msgid "" "Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $" "%(command)s" msgstr "危险命令告警: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s" -#: terminal/utils.py:80 +#: terminal/utils.py:81 #, python-format msgid "" "\n" @@ -2849,46 +2912,46 @@ msgstr "" msgid "Ticket has %s" msgstr "工单已%s" -#: tickets/api/request_asset_perm.py:62 +#: tickets/api/request_asset_perm.py:66 #: tickets/serializers/request_asset_perm.py:23 msgid "IP group" msgstr "IP组" -#: tickets/api/request_asset_perm.py:65 +#: tickets/api/request_asset_perm.py:69 #: tickets/serializers/request_asset_perm.py:35 msgid "Confirmed assets" msgstr "确认的资产" -#: tickets/api/request_asset_perm.py:66 +#: tickets/api/request_asset_perm.py:70 msgid "Confirmed system users" msgstr "确认的系统用户" -#: tickets/api/request_asset_perm.py:87 +#: tickets/api/request_asset_perm.py:91 msgid "Confirm assets first" msgstr "请先确认资产" -#: tickets/api/request_asset_perm.py:90 +#: tickets/api/request_asset_perm.py:94 msgid "Confirmed assets changed" msgstr "确认的资产变更了" -#: tickets/api/request_asset_perm.py:94 +#: tickets/api/request_asset_perm.py:98 msgid "Confirm system-users first" msgstr "请先确认系统用户" -#: tickets/api/request_asset_perm.py:98 +#: tickets/api/request_asset_perm.py:102 msgid "Confirmed system-users changed" msgstr "确认的系统用户变更了" -#: tickets/api/request_asset_perm.py:104 tickets/api/request_asset_perm.py:111 -#: xpack/plugins/cloud/models.py:214 +#: tickets/api/request_asset_perm.py:108 tickets/api/request_asset_perm.py:115 +#: xpack/plugins/cloud/models.py:215 msgid "Succeed" msgstr "成功" -#: tickets/api/request_asset_perm.py:118 +#: tickets/api/request_asset_perm.py:122 msgid "From request ticket: {} {}" msgstr "来自工单申请: {} {}" -#: tickets/api/request_asset_perm.py:120 +#: tickets/api/request_asset_perm.py:124 msgid "{} request assets, approved by {}" msgstr "{} 申请资产,通过人 {}" @@ -3063,7 +3126,7 @@ msgstr "" " </div>\n" " " -#: users/api/user.py:190 +#: users/api/user.py:199 msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" @@ -3109,7 +3172,7 @@ msgstr "确认密码" msgid "Password does not match" msgstr "密码不一致" -#: users/forms/profile.py:89 users/models/user.py:507 +#: users/forms/profile.py:89 users/models/user.py:497 #: users/templates/users/user_detail.html:57 #: users/templates/users/user_profile.html:59 msgid "Email" @@ -3145,12 +3208,12 @@ msgid "Public key should not be the same as your old one." msgstr "不能和原来的密钥相同" #: users/forms/profile.py:137 users/forms/user.py:90 -#: users/serializers/user.py:188 users/serializers/user.py:270 -#: users/serializers/user.py:328 +#: users/serializers/user.py:192 users/serializers/user.py:274 +#: users/serializers/user.py:332 msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/user.py:31 users/models/user.py:550 +#: users/forms/user.py:31 users/models/user.py:540 #: users/templates/users/user_detail.html:89 #: users/templates/users/user_list.html:18 #: users/templates/users/user_profile.html:102 @@ -3192,31 +3255,31 @@ msgstr "系统管理员" msgid "System auditor" msgstr "系统审计员" -#: users/models/user.py:427 users/templates/users/user_profile.html:90 +#: users/models/user.py:423 users/templates/users/user_profile.html:90 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:494 +#: users/models/user.py:485 msgid "Local" msgstr "数据库" -#: users/models/user.py:518 +#: users/models/user.py:508 msgid "Avatar" msgstr "头像" -#: users/models/user.py:521 users/templates/users/user_detail.html:68 +#: users/models/user.py:511 users/templates/users/user_detail.html:68 msgid "Wechat" msgstr "微信" -#: users/models/user.py:554 +#: users/models/user.py:544 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:661 +#: users/models/user.py:651 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:664 +#: users/models/user.py:654 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" @@ -3236,55 +3299,55 @@ msgstr "是否可更新" msgid "Can delete" msgstr "是否可删除" -#: users/serializers/user.py:49 users/serializers/user.py:81 +#: users/serializers/user.py:49 users/serializers/user.py:85 msgid "Organization role name" msgstr "组织角色名称" -#: users/serializers/user.py:74 users/serializers/user.py:241 +#: users/serializers/user.py:78 users/serializers/user.py:245 msgid "Is first login" msgstr "首次登录" -#: users/serializers/user.py:77 +#: users/serializers/user.py:81 msgid "Avatar url" msgstr "头像路径" -#: users/serializers/user.py:79 +#: users/serializers/user.py:83 msgid "Groups name" msgstr "用户组名" -#: users/serializers/user.py:80 +#: users/serializers/user.py:84 msgid "Source name" msgstr "用户来源名" -#: users/serializers/user.py:82 +#: users/serializers/user.py:86 msgid "Super role name" msgstr "超级角色名称" -#: users/serializers/user.py:83 +#: users/serializers/user.py:87 msgid "Total role name" msgstr "汇总角色名称" -#: users/serializers/user.py:84 +#: users/serializers/user.py:88 msgid "MFA enabled" msgstr "是否开启多因子认证" -#: users/serializers/user.py:85 +#: users/serializers/user.py:89 msgid "MFA force enabled" msgstr "强制启用多因子认证" -#: users/serializers/user.py:108 +#: users/serializers/user.py:112 msgid "Role limit to {}" msgstr "角色只能为 {}" -#: users/serializers/user.py:120 users/serializers/user.py:294 +#: users/serializers/user.py:124 users/serializers/user.py:298 msgid "Password does not match security rules" msgstr "密码不满足安全规则" -#: users/serializers/user.py:286 +#: users/serializers/user.py:290 msgid "The old password is incorrect" msgstr "旧密码错误" -#: users/serializers/user.py:300 +#: users/serializers/user.py:304 msgid "The newly set password is inconsistent" msgstr "两次密码不一致" @@ -3298,7 +3361,7 @@ msgstr "安全令牌验证" #: users/templates/users/_base_otp.html:14 users/templates/users/_user.html:13 #: users/templates/users/user_profile_update.html:55 -#: xpack/plugins/cloud/models.py:124 xpack/plugins/cloud/serializers.py:83 +#: xpack/plugins/cloud/models.py:125 xpack/plugins/cloud/serializers.py:114 msgid "Account" msgstr "账户" @@ -3429,13 +3492,6 @@ msgstr "很弱" msgid "Weak" msgstr "弱" -#: users/templates/users/reset_password.html:50 -#: users/templates/users/user_create.html:35 -#: users/templates/users/user_password_update.html:104 -#: users/templates/users/user_update.html:57 -msgid "Normal" -msgstr "正常" - #: users/templates/users/reset_password.html:51 #: users/templates/users/user_create.html:36 #: users/templates/users/user_password_update.html:105 @@ -3462,7 +3518,7 @@ msgstr "很强" #: users/templates/users/user_database_app_permission.html:41 #: users/templates/users/user_list.html:19 #: users/templates/users/user_remote_app_permission.html:41 -#: xpack/plugins/cloud/models.py:51 +#: xpack/plugins/cloud/models.py:52 msgid "Validity" msgstr "有效" @@ -4240,75 +4296,75 @@ msgstr "" msgid "Access key secret" msgstr "" -#: xpack/plugins/cloud/models.py:65 +#: xpack/plugins/cloud/models.py:66 msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:120 +#: xpack/plugins/cloud/models.py:121 msgid "Instance name" msgstr "实例名称" -#: xpack/plugins/cloud/models.py:121 +#: xpack/plugins/cloud/models.py:122 msgid "Instance name and Partial IP" msgstr "实例名称和部分IP" -#: xpack/plugins/cloud/models.py:127 xpack/plugins/cloud/serializers.py:59 +#: xpack/plugins/cloud/models.py:128 xpack/plugins/cloud/serializers.py:90 msgid "Regions" msgstr "地域" -#: xpack/plugins/cloud/models.py:130 +#: xpack/plugins/cloud/models.py:131 msgid "Instances" msgstr "实例" -#: xpack/plugins/cloud/models.py:134 +#: xpack/plugins/cloud/models.py:135 msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:146 xpack/plugins/cloud/serializers.py:87 +#: xpack/plugins/cloud/models.py:147 xpack/plugins/cloud/serializers.py:118 msgid "Always update" msgstr "总是更新" -#: xpack/plugins/cloud/models.py:152 +#: xpack/plugins/cloud/models.py:153 msgid "Date last sync" msgstr "最后同步日期" -#: xpack/plugins/cloud/models.py:163 xpack/plugins/cloud/models.py:219 +#: xpack/plugins/cloud/models.py:164 xpack/plugins/cloud/models.py:220 msgid "Sync instance task" msgstr "同步实例任务" -#: xpack/plugins/cloud/models.py:229 xpack/plugins/cloud/models.py:284 +#: xpack/plugins/cloud/models.py:230 xpack/plugins/cloud/models.py:285 msgid "Date sync" msgstr "同步日期" -#: xpack/plugins/cloud/models.py:257 +#: xpack/plugins/cloud/models.py:258 msgid "Unsync" msgstr "未同步" -#: xpack/plugins/cloud/models.py:258 +#: xpack/plugins/cloud/models.py:259 msgid "New Sync" msgstr "新同步" -#: xpack/plugins/cloud/models.py:259 +#: xpack/plugins/cloud/models.py:260 msgid "Synced" msgstr "已同步" -#: xpack/plugins/cloud/models.py:260 +#: xpack/plugins/cloud/models.py:261 msgid "Released" msgstr "已释放" -#: xpack/plugins/cloud/models.py:265 +#: xpack/plugins/cloud/models.py:266 msgid "Sync task" msgstr "同步任务" -#: xpack/plugins/cloud/models.py:269 +#: xpack/plugins/cloud/models.py:270 msgid "Sync instance task history" msgstr "同步实例任务历史" -#: xpack/plugins/cloud/models.py:272 +#: xpack/plugins/cloud/models.py:273 msgid "Instance" msgstr "实例" -#: xpack/plugins/cloud/models.py:275 +#: xpack/plugins/cloud/models.py:276 msgid "Region" msgstr "地域" @@ -4324,6 +4380,10 @@ msgstr "AWS (国际)" msgid "AWS (China)" msgstr "AWS (中国)" +#: xpack/plugins/cloud/providers/azure_.py:18 +msgid "Azure (China)" +msgstr "Azure (中国)" + #: xpack/plugins/cloud/providers/huaweicloud.py:20 msgid "Huawei Cloud" msgstr "华为云" @@ -4384,15 +4444,23 @@ msgstr "拉美-圣地亚哥" msgid "Tencent Cloud" msgstr "腾讯云" -#: xpack/plugins/cloud/serializers.py:57 +#: xpack/plugins/cloud/serializers.py:26 +msgid "Tenant ID" +msgstr "租户ID" + +#: xpack/plugins/cloud/serializers.py:30 +msgid "Subscription ID" +msgstr "订阅ID" + +#: xpack/plugins/cloud/serializers.py:88 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers.py:58 +#: xpack/plugins/cloud/serializers.py:89 msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:86 +#: xpack/plugins/cloud/serializers.py:117 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -4485,14 +4553,15 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" +#, python-format +#~ msgid "The max size of CSV is %d bytes" +#~ msgstr "CSV 文件最大为 %d 字节" + #, fuzzy #~| msgid "Confirmed system user" #~ msgid "Confirmed systemusers" #~ msgstr "确认的系统用户" -#~ msgid "Azure (China)" -#~ msgstr "Azure (中国)" - #~ msgid "MFA level" #~ msgstr "多因子认证级别" @@ -5028,9 +5097,6 @@ msgstr "社区版" #~ msgid "System user assets" #~ msgstr "系统用户关联资产" -#~ msgid "System user users" -#~ msgstr "系统用户关联用户" - #~ msgid "Select user" #~ msgstr "选择用户" @@ -5483,9 +5549,6 @@ msgstr "社区版" #~ msgid "List page size" #~ msgstr "资产分页每页数量" -#~ msgid "Session keep duration" -#~ msgstr "会话保留时长" - #~ msgid "" #~ "Units: days, Session, record, command will be delete if more than " #~ "duration, only in database" diff --git a/apps/ops/celery/__init__.py b/apps/ops/celery/__init__.py index b8ed56be1..0ded6bc52 100644 --- a/apps/ops/celery/__init__.py +++ b/apps/ops/celery/__init__.py @@ -29,16 +29,3 @@ configs["CELERY_ROUTES"] = { app.namespace = 'CELERY' app.conf.update(configs) app.autodiscover_tasks(lambda: [app_config.split('.')[0] for app_config in settings.INSTALLED_APPS]) - -app.conf.beat_schedule = { - 'check-asset-permission-expired': { - 'task': 'perms.tasks.check_asset_permission_expired', - 'schedule': settings.PERM_EXPIRED_CHECK_PERIODIC, - 'args': () - }, - 'check-node-assets-amount': { - 'task': 'assets.tasks.nodes_amount.check_node_assets_amount_celery_task', - 'schedule': crontab(minute=0, hour=0), - 'args': () - }, -} diff --git a/apps/ops/celery/utils.py b/apps/ops/celery/utils.py index 0c758b70e..ff5aeb1d4 100644 --- a/apps/ops/celery/utils.py +++ b/apps/ops/celery/utils.py @@ -3,6 +3,8 @@ import json import os +import redis_lock +import redis from django.conf import settings from django.utils.timezone import get_current_timezone from django.db.utils import ProgrammingError, OperationalError @@ -105,3 +107,27 @@ def get_celery_task_log_path(task_id): path = os.path.join(settings.CELERY_LOG_DIR, rel_path) os.makedirs(os.path.dirname(path), exist_ok=True) return path + + +def get_celery_status(): + from . import app + i = app.control.inspect() + ping_data = i.ping() or {} + active_nodes = [k for k, v in ping_data.items() if v.get('ok') == 'pong'] + active_queue_worker = set([n.split('@')[0] for n in active_nodes if n]) + if len(active_queue_worker) < 5: + print("Not all celery worker worked") + return False + else: + return True + + +def get_beat_status(): + CONFIG = settings.CONFIG + r = redis.Redis(host=CONFIG.REDIS_HOST, port=CONFIG.REDIS_PORT, password=CONFIG.REDIS_PASSWORD) + lock = redis_lock.Lock(r, name="beat-distribute-start-lock") + try: + locked = lock.locked() + return locked + except redis.ConnectionError: + return False diff --git a/apps/ops/management/commands/check_celery.py b/apps/ops/management/commands/check_celery.py new file mode 100644 index 000000000..41985129d --- /dev/null +++ b/apps/ops/management/commands/check_celery.py @@ -0,0 +1,20 @@ +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = 'Ops manage commands' + + def add_arguments(self, parser): + parser.add_argument('check_celery', nargs='?', help='Check celery health') + + def handle(self, *args, **options): + from ops.celery.utils import get_celery_status, get_beat_status + + ok = get_celery_status() + if not ok: + raise CommandError('Celery worker unhealthy') + + ok = get_beat_status() + if not ok: + raise CommandError('Beat unhealthy') + diff --git a/apps/orgs/api.py b/apps/orgs/api.py index ff3a38007..077b7eba7 100644 --- a/apps/orgs/api.py +++ b/apps/orgs/api.py @@ -92,6 +92,7 @@ class OrgMemberAdminRelationBulkViewSet(JMSBulkRelationModelViewSet): serializer_class = OrgMemberAdminSerializer filterset_class = OrgMemberRelationFilterSet search_fields = ('user__name', 'user__username', 'org__name') + lookup_field = 'user_id' def get_queryset(self): queryset = super().get_queryset() @@ -116,6 +117,7 @@ class OrgMemberUserRelationBulkViewSet(JMSBulkRelationModelViewSet): serializer_class = OrgMemberUserSerializer filterset_class = OrgMemberRelationFilterSet search_fields = ('user__name', 'user__username', 'org__name') + lookup_field = 'user_id' def get_queryset(self): queryset = super().get_queryset() diff --git a/apps/orgs/middleware.py b/apps/orgs/middleware.py index efbee2dde..2448fffc3 100644 --- a/apps/orgs/middleware.py +++ b/apps/orgs/middleware.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # -from .models import Organization from .utils import get_org_from_request, set_current_org diff --git a/apps/orgs/mixins/models.py b/apps/orgs/mixins/models.py index c6c18902d..f2f6ae49e 100644 --- a/apps/orgs/mixins/models.py +++ b/apps/orgs/mixins/models.py @@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError from common.utils import get_logger from ..utils import ( set_current_org, get_current_org, current_org, - filter_org_queryset + filter_org_queryset, get_org_by_id, get_org_name_by_id ) from ..models import Organization @@ -70,13 +70,11 @@ class OrgModelMixin(models.Model): @property def org(self): - from orgs.models import Organization - org = Organization.get_instance(self.org_id) - return org + return get_org_by_id(self.org_id) @property def org_name(self): - return self.org.name + return get_org_name_by_id(self.org_id) @property def fullname(self, attr=None): diff --git a/apps/orgs/serializers.py b/apps/orgs/serializers.py index 7a57b4fac..c4402d433 100644 --- a/apps/orgs/serializers.py +++ b/apps/orgs/serializers.py @@ -74,26 +74,29 @@ class OrgMemberSerializer(BulkModelSerializer): ).distinct() -class OrgMemberAdminSerializer(BulkModelSerializer): +class OrgMemberOldBaseSerializer(BulkModelSerializer): + organization = serializers.PrimaryKeyRelatedField( + label=_('Organization'), queryset=Organization.objects.all(), required=True, source='org' + ) + + def to_internal_value(self, data): + view = self.context['view'] + org_id = view.kwargs.get('org_id') + if org_id: + data['organization'] = org_id + return super().to_internal_value(data) + + class Meta: + model = OrganizationMember + fields = ('id', 'organization', 'user', 'role') + + +class OrgMemberAdminSerializer(OrgMemberOldBaseSerializer): role = serializers.HiddenField(default=ROLE.ADMIN) - organization = serializers.PrimaryKeyRelatedField( - label=_('Organization'), queryset=Organization.objects.all(), required=True, source='org' - ) - - class Meta: - model = OrganizationMember - fields = ('id', 'organization', 'user', 'role') -class OrgMemberUserSerializer(BulkModelSerializer): +class OrgMemberUserSerializer(OrgMemberOldBaseSerializer): role = serializers.HiddenField(default=ROLE.USER) - organization = serializers.PrimaryKeyRelatedField( - label=_('Organization'), queryset=Organization.objects.all(), required=True, source='org' - ) - - class Meta: - model = OrganizationMember - fields = ('id', 'organization', 'user', 'role') class OrgRetrieveSerializer(OrgReadSerializer): diff --git a/apps/orgs/utils.py b/apps/orgs/utils.py index 7a4576e23..c10a5dacc 100644 --- a/apps/orgs/utils.py +++ b/apps/orgs/utils.py @@ -65,6 +65,47 @@ def get_current_org_id(): return org_id +def construct_org_mapper(): + orgs = Organization.objects.all() + org_mapper = {str(org.id): org for org in orgs} + default_org = Organization.default() + org_mapper.update({ + '': default_org, + Organization.DEFAULT_ID: default_org, + Organization.ROOT_ID: Organization.root(), + Organization.SYSTEM_ID: Organization.system() + }) + return org_mapper + + +def set_org_mapper(org_mapper): + setattr(thread_local, 'org_mapper', org_mapper) + + +def get_org_mapper(): + org_mapper = _find('org_mapper') + if org_mapper is None: + org_mapper = construct_org_mapper() + set_org_mapper(org_mapper) + return org_mapper + + +def get_org_by_id(org_id): + org_id = str(org_id) + org_mapper = get_org_mapper() + org = org_mapper.get(org_id) + return org + + +def get_org_name_by_id(org_id): + org = get_org_by_id(org_id) + if org: + org_name = org.name + else: + org_name = 'Not Found' + return org_name + + def get_current_org_id_for_serializer(): org_id = get_current_org_id() if org_id == Organization.DEFAULT_ID: diff --git a/apps/perms/api/application/user_permission/user_permission_applications.py b/apps/perms/api/application/user_permission/user_permission_applications.py index c74adb3d1..848e22304 100644 --- a/apps/perms/api/application/user_permission/user_permission_applications.py +++ b/apps/perms/api/application/user_permission/user_permission_applications.py @@ -48,7 +48,7 @@ class ApplicationsAsTreeMixin(SerializeApplicationToTreeNodeMixin): def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) - data = self.serialize_applications(queryset) + data = self.serialize_applications_with_org(queryset) return Response(data=data) diff --git a/apps/perms/api/asset/user_group_permission.py b/apps/perms/api/asset/user_group_permission.py index b595e4132..fa71da9b5 100644 --- a/apps/perms/api/asset/user_group_permission.py +++ b/apps/perms/api/asset/user_group_permission.py @@ -32,9 +32,6 @@ class UserGroupMixin: class UserGroupGrantedAssetsApi(ListAPIView): - """ - 获取用户组直接授权的资产 - """ permission_classes = (IsOrgAdminOrAppUser,) serializer_class = serializers.AssetGrantedSerializer only_fields = serializers.AssetGrantedSerializer.Meta.only_fields @@ -44,11 +41,27 @@ class UserGroupGrantedAssetsApi(ListAPIView): def get_queryset(self): user_group_id = self.kwargs.get('pk', '') - return Asset.objects.filter( - Q(granted_by_permissions__user_groups__id=user_group_id) + asset_perms_id = list(AssetPermission.objects.valid().filter( + user_groups__id=user_group_id + ).distinct().values_list('id', flat=True)) + + granted_node_keys = Node.objects.filter( + granted_by_permissions__id__in=asset_perms_id, + ).distinct().values_list('key', flat=True) + + granted_q = Q() + for _key in granted_node_keys: + granted_q |= Q(nodes__key__startswith=f'{_key}:') + granted_q |= Q(nodes__key=_key) + + granted_q |= Q(granted_by_permissions__id__in=asset_perms_id) + + assets = Asset.objects.filter( + granted_q ).distinct().only( *self.only_fields ) + return assets class UserGroupGrantedNodeAssetsApi(ListAPIView): @@ -66,7 +79,7 @@ class UserGroupGrantedNodeAssetsApi(ListAPIView): granted = AssetPermission.objects.filter( user_groups__id=user_group_id, nodes__id=node_id - ).exists() + ).valid().exists() if granted: assets = Asset.objects.filter( Q(nodes__key__startswith=f'{node.key}:') | @@ -74,8 +87,12 @@ class UserGroupGrantedNodeAssetsApi(ListAPIView): ) return assets else: + asset_perms_id = list(AssetPermission.objects.valid().filter( + user_groups__id=user_group_id + ).distinct().values_list('id', flat=True)) + granted_node_keys = Node.objects.filter( - granted_by_permissions__user_groups__id=user_group_id, + granted_by_permissions__id__in=asset_perms_id, key__startswith=f'{node.key}:' ).distinct().values_list('key', flat=True) @@ -85,7 +102,7 @@ class UserGroupGrantedNodeAssetsApi(ListAPIView): granted_node_q |= Q(nodes__key=_key) granted_asset_q = ( - Q(granted_by_permissions__user_groups__id=user_group_id) & + Q(granted_by_permissions__id__in=asset_perms_id) & ( Q(nodes__key__startswith=f'{node.key}:') | Q(nodes__key=node.key) @@ -129,12 +146,16 @@ class UserGroupGrantedNodeChildrenAsTreeApi(SerializeToTreeNodeMixin, ListAPIVie group_id = self.kwargs.get('pk') node_key = self.request.query_params.get('key', None) + asset_perms_id = list(AssetPermission.objects.valid().filter( + user_groups__id=group_id + ).distinct().values_list('id', flat=True)) + granted_keys = Node.objects.filter( - granted_by_permissions__user_groups__id=group_id + granted_by_permissions__id__in=asset_perms_id ).values_list('key', flat=True) asset_granted_keys = Node.objects.filter( - assets__granted_by_permissions__user_groups__id=group_id + assets__granted_by_permissions__id__in=asset_perms_id ).values_list('key', flat=True) if node_key is None: diff --git a/apps/perms/api/asset/user_permission/user_permission_assets.py b/apps/perms/api/asset/user_permission/user_permission_assets.py index 949f12c67..35b9da257 100644 --- a/apps/perms/api/asset/user_permission/user_permission_assets.py +++ b/apps/perms/api/asset/user_permission/user_permission_assets.py @@ -3,6 +3,7 @@ from perms.api.asset.user_permission.mixin import UserNodeGrantStatusDispatchMixin from rest_framework.generics import ListAPIView from rest_framework.response import Response +from rest_framework.request import Request from django.conf import settings from assets.api.mixin import SerializeToTreeNodeMixin @@ -55,8 +56,12 @@ class AssetsAsTreeMixin(SerializeToTreeNodeMixin): """ 将 资产 序列化成树的结构返回 """ - def list(self, request, *args, **kwargs): + def list(self, request: Request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) + if request.query_params.get('search'): + # 如果用户搜索的条件不精准,会导致返回大量的无意义数据。 + # 这里限制一下返回数据的最大条数 + queryset = queryset[:999] data = self.serialize_assets(queryset, None) return Response(data=data) diff --git a/apps/perms/api/asset/user_permission/user_permission_nodes_with_assets.py b/apps/perms/api/asset/user_permission/user_permission_nodes_with_assets.py index 4f9de071d..44d8f22b3 100644 --- a/apps/perms/api/asset/user_permission/user_permission_nodes_with_assets.py +++ b/apps/perms/api/asset/user_permission/user_permission_nodes_with_assets.py @@ -139,11 +139,13 @@ class MyGrantedNodesWithAssetsAsTreeApi(SerializeToTreeNodeMixin, ListAPIView): return Response(data=data) -class UserGrantedNodeChildrenWithAssetsAsTreeForAdminApi(ForAdminMixin, UserNodeGrantStatusDispatchMixin, - SerializeToTreeNodeMixin, ListAPIView): +class GrantedNodeChildrenWithAssetsAsTreeApiMixin(UserNodeGrantStatusDispatchMixin, + SerializeToTreeNodeMixin, + ListAPIView): """ 带资产的授权树 """ + user: None def get_data_on_node_direct_granted(self, key): nodes = Node.objects.filter(parent_key=key) @@ -203,5 +205,9 @@ class UserGrantedNodeChildrenWithAssetsAsTreeForAdminApi(ForAdminMixin, UserNode return Response(data=[*tree_nodes, *tree_assets]) -class MyGrantedNodeChildrenWithAssetsAsTreeApi(ForUserMixin, UserGrantedNodeChildrenWithAssetsAsTreeForAdminApi): +class UserGrantedNodeChildrenWithAssetsAsTreeApi(ForAdminMixin, GrantedNodeChildrenWithAssetsAsTreeApiMixin): + pass + + +class MyGrantedNodeChildrenWithAssetsAsTreeApi(ForUserMixin, GrantedNodeChildrenWithAssetsAsTreeApiMixin): pass diff --git a/apps/perms/signals_handler.py b/apps/perms/signals_handler.py index fe39804f6..5b33fcb35 100644 --- a/apps/perms/signals_handler.py +++ b/apps/perms/signals_handler.py @@ -6,7 +6,7 @@ from django.dispatch import receiver from perms.tasks import create_rebuild_user_tree_task, \ create_rebuild_user_tree_task_by_related_nodes_or_assets from users.models import User, UserGroup -from assets.models import Asset +from assets.models import Asset, SystemUser from common.utils import get_logger from common.exceptions import M2MReverseNotAllowed from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR @@ -16,6 +16,42 @@ from .models import AssetPermission, RemoteAppPermission logger = get_logger(__file__) +def handle_rebuild_user_tree(instance, action, reverse, pk_set, **kwargs): + if action.startswith('post'): + if reverse: + create_rebuild_user_tree_task(pk_set) + else: + create_rebuild_user_tree_task([instance.id]) + + +def handle_bind_groups_systemuser(instance, action, reverse, pk_set, **kwargs): + """ + UserGroup 增加 User 时,增加的 User 需要与 UserGroup 关联的动态系统用户相关联 + """ + user: User + + if action != POST_ADD: + return + + if not reverse: + # 一个用户添加了多个用户组 + users_id = [instance.id] + system_users = SystemUser.objects.filter(groups__id__in=pk_set).distinct() + else: + # 一个用户组添加了多个用户 + users_id = pk_set + system_users = SystemUser.objects.filter(groups__id=instance.pk).distinct() + + for system_user in system_users: + system_user.users.add(*users_id) + + +@receiver(m2m_changed, sender=User.groups.through) +def on_user_groups_change(**kwargs): + handle_rebuild_user_tree(**kwargs) + handle_bind_groups_systemuser(**kwargs) + + @receiver([pre_save], sender=AssetPermission) def on_asset_perm_deactive(instance: AssetPermission, **kwargs): try: diff --git a/apps/perms/tasks.py b/apps/perms/tasks.py index 7e940d594..fbf2ce8be 100644 --- a/apps/perms/tasks.py +++ b/apps/perms/tasks.py @@ -5,10 +5,12 @@ from datetime import timedelta from django.db import transaction from django.db.models import Q from django.db.transaction import atomic +from django.conf import settings from celery import shared_task from common.utils import get_logger from common.utils.timezone import now, dt_formater, dt_parser from users.models import User +from ops.celery.decorator import register_as_period_task from assets.models import Node from perms.models import RebuildUserTreeTask, AssetPermission from perms.utils.asset.user_permission import rebuild_user_mapping_nodes_if_need_with_lock, lock @@ -33,7 +35,8 @@ def dispatch_mapping_node_tasks(): rebuild_user_mapping_nodes_celery_task.delay(id) -@shared_task(queue='check_asset_perm_expired') +@register_as_period_task(interval=settings.PERM_EXPIRED_CHECK_PERIODIC) +@shared_task(queue='celery_check_asset_perm_expired') @atomic() def check_asset_permission_expired(): """ diff --git a/apps/perms/urls/asset_permission.py b/apps/perms/urls/asset_permission.py index de441bbfc..7b251dea7 100644 --- a/apps/perms/urls/asset_permission.py +++ b/apps/perms/urls/asset_permission.py @@ -21,7 +21,7 @@ user_permission_urlpatterns = [ # --------------------------------------------------------- # 以 serializer 格式返回 path('<uuid:pk>/assets/', api.UserAllGrantedAssetsApi.as_view(), name='user-assets'), - path('assets/', api.MyAllAssetsAsTreeApi.as_view(), name='my-assets'), + path('assets/', api.MyAllGrantedAssetsApi.as_view(), name='my-assets'), # Tree Node 的数据格式返回 path('<uuid:pk>/assets/tree/', api.UserDirectGrantedAssetsAsTreeForAdminApi.as_view(), name='user-assets-as-tree'), @@ -56,7 +56,7 @@ user_permission_urlpatterns = [ path('nodes-with-assets/tree/', api.MyGrantedNodesWithAssetsAsTreeApi.as_view(), name='my-nodes-with-assets-as-tree'), # 主要用于 luna 页面,带资产的节点树 - path('<uuid:pk>/nodes/children-with-assets/tree/', api.UserGrantedNodeChildrenWithAssetsAsTreeForAdminApi.as_view(), name='user-nodes-children-with-assets-as-tree'), + path('<uuid:pk>/nodes/children-with-assets/tree/', api.UserGrantedNodeChildrenWithAssetsAsTreeApi.as_view(), name='user-nodes-children-with-assets-as-tree'), path('nodes/children-with-assets/tree/', api.MyGrantedNodeChildrenWithAssetsAsTreeApi.as_view(), name='my-nodes-children-with-assets-as-tree'), # 查询授权树上某个节点的所有资产 diff --git a/apps/perms/utils/asset/user_permission.py b/apps/perms/utils/asset/user_permission.py index 274a27d4d..5b9836400 100644 --- a/apps/perms/utils/asset/user_permission.py +++ b/apps/perms/utils/asset/user_permission.py @@ -34,27 +34,6 @@ TMP_ASSET_GRANTED_FIELD = '_asset_granted' TMP_GRANTED_ASSETS_AMOUNT_FIELD = '_granted_assets_amount' -# 使用场景 -# Asset.objects.filter(get_user_resources_q_granted_by_permissions(user)) -def get_user_resources_q_granted_by_permissions(user: User): - """ - 获取用户关联的 asset permission 或者 用户组关联的 asset permission 获取规则, - 前提 AssetPermission 对象中的 related_name 为 granted_by_permissions - :param user: - :return: - """ - _now = now() - return reduce(and_, ( - Q(granted_by_permissions__date_start__lt=_now), - Q(granted_by_permissions__date_expired__gt=_now), - Q(granted_by_permissions__is_active=True), - ( - Q(granted_by_permissions__users=user) | - Q(granted_by_permissions__user_groups__users=user) - ) - )) - - # 使用场景 # `Node.objects.annotate(**node_annotate_mapping_node)` node_annotate_mapping_node = { @@ -215,7 +194,7 @@ def compute_tmp_mapping_node_from_perm(user: User, asset_perms_id=None): return [*leaf_nodes, *ancestors] -def create_mapping_nodes(user, nodes, clear=True): +def create_mapping_nodes(user, nodes): to_create = [] for node in nodes: _granted = getattr(node, TMP_GRANTED_FIELD, False) @@ -231,8 +210,6 @@ def create_mapping_nodes(user, nodes, clear=True): assets_amount=_granted_assets_amount, )) - if clear: - UserGrantedMappingNode.objects.filter(user=user).delete() UserGrantedMappingNode.objects.bulk_create(to_create) @@ -254,6 +231,9 @@ def set_node_granted_assets_amount(user, node, asset_perms_id=None): @tmp_to_root_org() def rebuild_user_mapping_nodes(user): logger.info(f'>>> {dt_formater(now())} start rebuild {user} mapping nodes') + + # 先删除旧的授权树🌲 + UserGrantedMappingNode.objects.filter(user=user).delete() asset_perms_id = get_user_all_assetpermissions_id(user) if not asset_perms_id: # 没有授权直接返回 @@ -384,7 +364,8 @@ def get_node_all_granted_assets(user: User, key): if only_asset_granted_nodes_qs: only_asset_granted_nodes_q = reduce(or_, only_asset_granted_nodes_qs) - only_asset_granted_nodes_q &= get_user_resources_q_granted_by_permissions(user) + asset_perms_id = get_user_all_assetpermissions_id(user) + only_asset_granted_nodes_q &= Q(granted_by_permissions__id__in=list(asset_perms_id)) q.append(only_asset_granted_nodes_q) if q: @@ -484,6 +465,9 @@ def get_user_all_assetpermissions_id(user: User): asset_perms_id = AssetPermission.objects.valid().filter( Q(users=user) | Q(user_groups__users=user) ).distinct().values_list('id', flat=True) + + # !!! 这个很重要,必须转换成 list,避免 Django 生成嵌套子查询 + asset_perms_id = list(asset_perms_id) return asset_perms_id diff --git a/apps/settings/utils/ldap.py b/apps/settings/utils/ldap.py index 3193a0a73..5ca455380 100644 --- a/apps/settings/utils/ldap.py +++ b/apps/settings/utils/ldap.py @@ -333,7 +333,7 @@ class LDAPImportUtil(object): def update_or_create(self, user): user['email'] = self.get_user_email(user) if user['username'] not in ['admin']: - user['source'] = User.SOURCE_LDAP + user['source'] = User.Source.ldap.value obj, created = User.objects.update_or_create( username=user['username'], defaults=user ) diff --git a/apps/templates/captcha/field.html b/apps/templates/captcha/field.html deleted file mode 100644 index 6979e870c..000000000 --- a/apps/templates/captcha/field.html +++ /dev/null @@ -1,12 +0,0 @@ -{{image}}{{hidden_field}}{{text_field}} - -<script> - function refresh_captcha() { - $.getJSON("{% url "captcha-refresh" %}", - function (result) { - $('.captcha').attr('src', result['image_url']); - $('#id_captcha_0').val(result['key']) - }) - } - $('.captcha').click(refresh_captcha) -</script> \ No newline at end of file diff --git a/apps/templates/captcha/hidden_field.html b/apps/templates/captcha/hidden_field.html deleted file mode 100644 index 36d7490a3..000000000 --- a/apps/templates/captcha/hidden_field.html +++ /dev/null @@ -1 +0,0 @@ -<input id="{{id}}_0" name="{{name}}_0" type="hidden" value="{{key}}" /> diff --git a/apps/templates/captcha/image.html b/apps/templates/captcha/image.html deleted file mode 100644 index b4a415536..000000000 --- a/apps/templates/captcha/image.html +++ /dev/null @@ -1,4 +0,0 @@ -{% load i18n %} -{% spaceless %} - {% if audio %}<a title="{% trans "Play CAPTCHA as audio file" %}" href="{{audio}}">{% endif %}<img src="{{image}}" alt="captcha" class="captcha" />{% if audio %}</a>{% endif %} -{% endspaceless %} \ No newline at end of file diff --git a/apps/templates/captcha/text_field.html b/apps/templates/captcha/text_field.html deleted file mode 100644 index 413eb1893..000000000 --- a/apps/templates/captcha/text_field.html +++ /dev/null @@ -1,7 +0,0 @@ -{% load i18n %} -<div class="row"> - <div class="col-sm-6"> - <input autocomplete="off" id="{{id}}_1" class="form-control" name="{{name}}_1" placeholder="{% trans 'Captcha' %}" type="text" /> - </div> -</div> -</br> diff --git a/apps/terminal/api/__init__.py b/apps/terminal/api/__init__.py index 640dacb6a..c0c6b8197 100644 --- a/apps/terminal/api/__init__.py +++ b/apps/terminal/api/__init__.py @@ -5,3 +5,4 @@ from .session import * from .command import * from .task import * from .storage import * +from .component import * diff --git a/apps/terminal/api/component.py b/apps/terminal/api/component.py new file mode 100644 index 000000000..aec404370 --- /dev/null +++ b/apps/terminal/api/component.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# + +import logging +from rest_framework import generics, status +from rest_framework.views import Response + +from .. import serializers +from ..utils import ComponentsMetricsUtil +from common.permissions import IsAppUser, IsSuperUser + +logger = logging.getLogger(__file__) + + +__all__ = [ + 'ComponentsStateAPIView', 'ComponentsMetricsAPIView', +] + + +class ComponentsStateAPIView(generics.CreateAPIView): + """ koko, guacamole, omnidb 上报状态 """ + permission_classes = (IsAppUser,) + serializer_class = serializers.ComponentsStateSerializer + + +class ComponentsMetricsAPIView(generics.GenericAPIView): + """ 返回汇总组件指标数据 """ + permission_classes = (IsSuperUser,) + + def get(self, request, *args, **kwargs): + component_type = request.query_params.get('type') + util = ComponentsMetricsUtil(component_type) + metrics = util.get_metrics() + return Response(metrics, status=status.HTTP_200_OK) diff --git a/apps/terminal/api/terminal.py b/apps/terminal/api/terminal.py index 2ee353e3e..a5e976034 100644 --- a/apps/terminal/api/terminal.py +++ b/apps/terminal/api/terminal.py @@ -27,7 +27,7 @@ class TerminalViewSet(JMSBulkModelViewSet): queryset = Terminal.objects.filter(is_deleted=False) serializer_class = serializers.TerminalSerializer permission_classes = (IsSuperUser,) - filter_fields = ['name', 'remote_addr'] + filter_fields = ['name', 'remote_addr', 'type'] def create(self, request, *args, **kwargs): if isinstance(request.data, list): @@ -60,6 +60,15 @@ class TerminalViewSet(JMSBulkModelViewSet): logger.error("Register terminal error: {}".format(data)) return Response(data, status=400) + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + status = self.request.query_params.get('status') + if not status: + return queryset + filtered_queryset_id = [str(q.id) for q in queryset if q.status == status] + queryset = queryset.filter(id__in=filtered_queryset_id) + return queryset + def get_permissions(self): if self.action == "create": self.permission_classes = (AllowAny,) @@ -104,15 +113,11 @@ class StatusViewSet(viewsets.ModelViewSet): task_serializer_class = serializers.TaskSerializer def create(self, request, *args, **kwargs): - self.handle_status(request) self.handle_sessions() tasks = self.request.user.terminal.task_set.filter(is_finished=False) serializer = self.task_serializer_class(tasks, many=True) return Response(serializer.data, status=201) - def handle_status(self, request): - request.user.terminal.is_alive = True - def handle_sessions(self): sessions_id = self.request.data.get('sessions', []) # guacamole 上报的 session 是字符串 diff --git a/apps/terminal/const.py b/apps/terminal/const.py index 4d19d007c..b7a48fab3 100644 --- a/apps/terminal/const.py +++ b/apps/terminal/const.py @@ -108,3 +108,27 @@ COMMAND_STORAGE_TYPE_CHOICES_EXTENDS = [ COMMAND_STORAGE_TYPE_CHOICES = COMMAND_STORAGE_TYPE_CHOICES_DEFAULT + \ COMMAND_STORAGE_TYPE_CHOICES_EXTENDS + +from django.db.models import TextChoices +from django.utils.translation import ugettext_lazy as _ + + +class ComponentStatusChoices(TextChoices): + critical = 'critical', _('Critical') + high = 'high', _('High') + normal = 'normal', _('Normal') + + @classmethod + def status(cls): + return set(dict(cls.choices).keys()) + + +class TerminalTypeChoices(TextChoices): + koko = 'koko', 'KoKo' + guacamole = 'guacamole', 'Guacamole' + omnidb = 'omnidb', 'OmniDB' + + @classmethod + def types(cls): + return set(dict(cls.choices).keys()) + diff --git a/apps/terminal/migrations/0030_terminal_type.py b/apps/terminal/migrations/0030_terminal_type.py new file mode 100644 index 000000000..4e4d871f8 --- /dev/null +++ b/apps/terminal/migrations/0030_terminal_type.py @@ -0,0 +1,42 @@ +# Generated by Django 3.1 on 2020-12-10 07:05 + +from django.db import migrations, models + +TERMINAL_TYPE_KOKO = 'koko' +TERMINAL_TYPE_GUACAMOLE = 'guacamole' +TERMINAL_TYPE_OMNIDB = 'omnidb' + + +def migrate_terminal_type(apps, schema_editor): + terminal_model = apps.get_model("terminal", "Terminal") + db_alias = schema_editor.connection.alias + terminals = terminal_model.objects.using(db_alias).all() + for terminal in terminals: + name = terminal.name.lower() + if 'koko' in name: + _type = TERMINAL_TYPE_KOKO + elif 'gua' in name: + _type = TERMINAL_TYPE_GUACAMOLE + elif 'omnidb' in name: + _type = TERMINAL_TYPE_OMNIDB + else: + _type = TERMINAL_TYPE_KOKO + terminal.type = _type + terminal_model.objects.bulk_update(terminals, ['type']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0029_auto_20201116_1757'), + ] + + operations = [ + migrations.AddField( + model_name='terminal', + name='type', + field=models.CharField(choices=[('koko', 'KoKo'), ('guacamole', 'Guacamole'), ('omnidb', 'OmniDB')], default='koko', max_length=64, verbose_name='type'), + preserve_default=False, + ), + migrations.RunPython(migrate_terminal_type) + ] diff --git a/apps/terminal/models.py b/apps/terminal/models.py deleted file mode 100644 index 17eb59f00..000000000 --- a/apps/terminal/models.py +++ /dev/null @@ -1,486 +0,0 @@ -from __future__ import unicode_literals - -import os -import uuid -import jms_storage - -from django.db import models -from django.db.models.signals import post_save -from django.utils.translation import ugettext_lazy as _ -from django.utils import timezone -from django.conf import settings -from django.core.files.storage import default_storage -from django.core.cache import cache - -from assets.models import Asset -from users.models import User -from orgs.mixins.models import OrgModelMixin -from common.mixins import CommonModelMixin -from common.fields.model import EncryptJsonDictTextField -from common.db.models import ChoiceSet -from .backends import get_multi_command_storage -from .backends.command.models import AbstractSessionCommand -from . import const - - -class Terminal(models.Model): - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - name = models.CharField(max_length=128, verbose_name=_('Name')) - remote_addr = models.CharField(max_length=128, blank=True, verbose_name=_('Remote Address')) - ssh_port = models.IntegerField(verbose_name=_('SSH Port'), default=2222) - http_port = models.IntegerField(verbose_name=_('HTTP Port'), default=5000) - command_storage = models.CharField(max_length=128, verbose_name=_("Command storage"), default='default') - replay_storage = models.CharField(max_length=128, verbose_name=_("Replay storage"), default='default') - user = models.OneToOneField(User, related_name='terminal', verbose_name='Application User', null=True, on_delete=models.CASCADE) - is_accepted = models.BooleanField(default=False, verbose_name='Is Accepted') - is_deleted = models.BooleanField(default=False) - date_created = models.DateTimeField(auto_now_add=True) - comment = models.TextField(blank=True, verbose_name=_('Comment')) - STATUS_KEY_PREFIX = 'terminal_status_' - - @property - def is_alive(self): - key = self.STATUS_KEY_PREFIX + str(self.id) - return bool(cache.get(key)) - - @is_alive.setter - def is_alive(self, value): - key = self.STATUS_KEY_PREFIX + str(self.id) - cache.set(key, value, 60) - - @property - def is_active(self): - if self.user and self.user.is_active: - return True - return False - - @is_active.setter - def is_active(self, active): - if self.user: - self.user.is_active = active - self.user.save() - - def get_command_storage(self): - storage = CommandStorage.objects.filter(name=self.command_storage).first() - return storage - - def get_command_storage_config(self): - s = self.get_command_storage() - if s: - config = s.config - else: - config = settings.DEFAULT_TERMINAL_COMMAND_STORAGE - return config - - def get_command_storage_setting(self): - config = self.get_command_storage_config() - return {"TERMINAL_COMMAND_STORAGE": config} - - def get_replay_storage(self): - storage = ReplayStorage.objects.filter(name=self.replay_storage).first() - return storage - - def get_replay_storage_config(self): - s = self.get_replay_storage() - if s: - config = s.config - else: - config = settings.DEFAULT_TERMINAL_REPLAY_STORAGE - return config - - def get_replay_storage_setting(self): - config = self.get_replay_storage_config() - return {"TERMINAL_REPLAY_STORAGE": config} - - @staticmethod - def get_login_title_setting(): - login_title = None - if settings.XPACK_ENABLED: - from xpack.plugins.interface.models import Interface - login_title = Interface.get_login_title() - return {'TERMINAL_HEADER_TITLE': login_title} - - @property - def config(self): - configs = {} - for k in dir(settings): - if not k.startswith('TERMINAL'): - continue - configs[k] = getattr(settings, k) - configs.update(self.get_command_storage_setting()) - configs.update(self.get_replay_storage_setting()) - configs.update(self.get_login_title_setting()) - configs.update({ - 'SECURITY_MAX_IDLE_TIME': settings.SECURITY_MAX_IDLE_TIME - }) - return configs - - @property - def service_account(self): - return self.user - - def create_app_user(self): - random = uuid.uuid4().hex[:6] - user, access_key = User.create_app_user( - name="{}-{}".format(self.name, random), comment=self.comment - ) - self.user = user - self.save() - return user, access_key - - def delete(self, using=None, keep_parents=False): - if self.user: - self.user.delete() - self.user = None - self.is_deleted = True - self.save() - return - - def __str__(self): - status = "Active" - if not self.is_accepted: - status = "NotAccept" - elif self.is_deleted: - status = "Deleted" - elif not self.is_active: - status = "Disable" - return '%s: %s' % (self.name, status) - - class Meta: - ordering = ('is_accepted',) - db_table = "terminal" - - -class Status(models.Model): - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - session_online = models.IntegerField(verbose_name=_("Session Online"), default=0) - cpu_used = models.FloatField(verbose_name=_("CPU Usage")) - memory_used = models.FloatField(verbose_name=_("Memory Used")) - connections = models.IntegerField(verbose_name=_("Connections")) - threads = models.IntegerField(verbose_name=_("Threads")) - boot_time = models.FloatField(verbose_name=_("Boot Time")) - terminal = models.ForeignKey(Terminal, null=True, on_delete=models.CASCADE) - date_created = models.DateTimeField(auto_now_add=True) - - class Meta: - db_table = 'terminal_status' - get_latest_by = 'date_created' - - def __str__(self): - return self.date_created.strftime("%Y-%m-%d %H:%M:%S") - - -class Session(OrgModelMixin): - class LOGIN_FROM(ChoiceSet): - ST = 'ST', 'SSH Terminal' - WT = 'WT', 'Web Terminal' - - class PROTOCOL(ChoiceSet): - SSH = 'ssh', 'ssh' - RDP = 'rdp', 'rdp' - VNC = 'vnc', 'vnc' - TELNET = 'telnet', 'telnet' - MYSQL = 'mysql', 'mysql' - ORACLE = 'oracle', 'oracle' - MARIADB = 'mariadb', 'mariadb' - POSTGRESQL = 'postgresql', 'postgresql' - K8S = 'k8s', 'kubernetes' - - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - user = models.CharField(max_length=128, verbose_name=_("User"), db_index=True) - user_id = models.CharField(blank=True, default='', max_length=36, db_index=True) - asset = models.CharField(max_length=128, verbose_name=_("Asset"), db_index=True) - asset_id = models.CharField(blank=True, default='', max_length=36, db_index=True) - system_user = models.CharField(max_length=128, verbose_name=_("System user"), db_index=True) - system_user_id = models.CharField(blank=True, default='', max_length=36, db_index=True) - login_from = models.CharField(max_length=2, choices=LOGIN_FROM.choices, default="ST", verbose_name=_("Login from")) - remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True) - is_success = models.BooleanField(default=True, db_index=True) - is_finished = models.BooleanField(default=False, db_index=True) - has_replay = models.BooleanField(default=False, verbose_name=_("Replay")) - has_command = models.BooleanField(default=False, verbose_name=_("Command")) - terminal = models.ForeignKey(Terminal, null=True, on_delete=models.DO_NOTHING, db_constraint=False) - protocol = models.CharField(choices=PROTOCOL.choices, default='ssh', max_length=16, db_index=True) - date_start = models.DateTimeField(verbose_name=_("Date start"), db_index=True, default=timezone.now) - date_end = models.DateTimeField(verbose_name=_("Date end"), null=True) - - upload_to = 'replay' - ACTIVE_CACHE_KEY_PREFIX = 'SESSION_ACTIVE_{}' - _DATE_START_FIRST_HAS_REPLAY_RDP_SESSION = None - - def get_rel_replay_path(self, version=2): - """ - 获取session日志的文件路径 - :param version: 原来后缀是 .gz,为了统一新版本改为 .replay.gz - :return: - """ - suffix = '.replay.gz' - if version == 1: - suffix = '.gz' - date = self.date_start.strftime('%Y-%m-%d') - return os.path.join(date, str(self.id) + suffix) - - def get_local_path(self, version=2): - rel_path = self.get_rel_replay_path(version=version) - if version == 2: - local_path = os.path.join(self.upload_to, rel_path) - else: - local_path = rel_path - return local_path - - @property - def asset_obj(self): - return Asset.objects.get(id=self.asset_id) - - @property - def _date_start_first_has_replay_rdp_session(self): - if self.__class__._DATE_START_FIRST_HAS_REPLAY_RDP_SESSION is None: - instance = self.__class__.objects.filter( - protocol='rdp', has_replay=True - ).order_by('date_start').first() - if not instance: - date_start = timezone.now() - timezone.timedelta(days=365) - else: - date_start = instance.date_start - self.__class__._DATE_START_FIRST_HAS_REPLAY_RDP_SESSION = date_start - return self.__class__._DATE_START_FIRST_HAS_REPLAY_RDP_SESSION - - def can_replay(self): - if self.has_replay: - return True - if self.date_start < self._date_start_first_has_replay_rdp_session: - return True - return False - - @property - def can_join(self): - _PROTOCOL = self.PROTOCOL - if self.is_finished: - return False - if self.protocol in [_PROTOCOL.SSH, _PROTOCOL.TELNET, _PROTOCOL.K8S]: - return True - else: - return False - - @property - def db_protocols(self): - _PROTOCOL = self.PROTOCOL - return [_PROTOCOL.MYSQL, _PROTOCOL.MARIADB, _PROTOCOL.ORACLE, _PROTOCOL.POSTGRESQL] - - @property - def can_terminate(self): - _PROTOCOL = self.PROTOCOL - if self.is_finished: - return False - if self.protocol in self.db_protocols: - return False - else: - return True - - def save_replay_to_storage(self, f): - local_path = self.get_local_path() - try: - name = default_storage.save(local_path, f) - except OSError as e: - return None, e - - if settings.SERVER_REPLAY_STORAGE: - from .tasks import upload_session_replay_to_external_storage - upload_session_replay_to_external_storage.delay(str(self.id)) - return name, None - - @classmethod - def set_sessions_active(cls, sessions_id): - data = {cls.ACTIVE_CACHE_KEY_PREFIX.format(i): i for i in sessions_id} - cache.set_many(data, timeout=5*60) - - @classmethod - def get_active_sessions(cls): - return cls.objects.filter(is_finished=False) - - def is_active(self): - if self.protocol in ['ssh', 'telnet', 'rdp', 'mysql']: - key = self.ACTIVE_CACHE_KEY_PREFIX.format(self.id) - return bool(cache.get(key)) - return True - - @property - def command_amount(self): - command_store = get_multi_command_storage() - return command_store.count(session=str(self.id)) - - @property - def login_from_display(self): - return self.get_login_from_display() - - @classmethod - def generate_fake(cls, count=100, is_finished=True): - import random - from orgs.models import Organization - from users.models import User - from assets.models import Asset, SystemUser - from orgs.utils import get_current_org - from common.utils.random import random_datetime, random_ip - - org = get_current_org() - if not org or not org.is_real(): - Organization.default().change_to() - i = 0 - users = User.objects.all()[:100] - assets = Asset.objects.all()[:100] - system_users = SystemUser.objects.all()[:100] - while i < count: - user_random = random.choices(users, k=10) - assets_random = random.choices(assets, k=10) - system_users = random.choices(system_users, k=10) - - ziped = zip(user_random, assets_random, system_users) - sessions = [] - now = timezone.now() - month_ago = now - timezone.timedelta(days=30) - for user, asset, system_user in ziped: - ip = random_ip() - date_start = random_datetime(month_ago, now) - date_end = random_datetime(date_start, date_start+timezone.timedelta(hours=2)) - data = dict( - user=str(user), user_id=user.id, - asset=str(asset), asset_id=asset.id, - system_user=str(system_user), system_user_id=system_user.id, - remote_addr=ip, - date_start=date_start, - date_end=date_end, - is_finished=is_finished, - ) - sessions.append(Session(**data)) - cls.objects.bulk_create(sessions) - i += 10 - - class Meta: - db_table = "terminal_session" - ordering = ["-date_start"] - - def __str__(self): - return "{0.id} of {0.user} to {0.asset}".format(self) - - -class Task(models.Model): - NAME_CHOICES = ( - ("kill_session", "Kill Session"), - ) - - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - name = models.CharField(max_length=128, choices=NAME_CHOICES, verbose_name=_("Name")) - args = models.CharField(max_length=1024, verbose_name=_("Args")) - terminal = models.ForeignKey(Terminal, null=True, on_delete=models.SET_NULL) - is_finished = models.BooleanField(default=False) - date_created = models.DateTimeField(auto_now_add=True) - date_finished = models.DateTimeField(null=True) - - class Meta: - db_table = "terminal_task" - - -class CommandManager(models.Manager): - def bulk_create(self, objs, **kwargs): - resp = super().bulk_create(objs, **kwargs) - for i in objs: - post_save.send(i.__class__, instance=i, created=True) - return resp - - -class Command(AbstractSessionCommand): - objects = CommandManager() - - class Meta: - db_table = "terminal_command" - ordering = ('-timestamp',) - - -class CommandStorage(CommonModelMixin): - TYPE_CHOICES = const.COMMAND_STORAGE_TYPE_CHOICES - TYPE_DEFAULTS = dict(const.REPLAY_STORAGE_TYPE_CHOICES_DEFAULT).keys() - TYPE_SERVER = const.COMMAND_STORAGE_TYPE_SERVER - - name = models.CharField(max_length=128, verbose_name=_("Name"), unique=True) - type = models.CharField( - max_length=16, choices=TYPE_CHOICES, verbose_name=_('Type'), - default=TYPE_SERVER - ) - meta = EncryptJsonDictTextField(default={}) - comment = models.TextField( - max_length=128, default='', blank=True, verbose_name=_('Comment') - ) - - def __str__(self): - return self.name - - @property - def config(self): - config = self.meta - config.update({'TYPE': self.type}) - return config - - def in_defaults(self): - return self.type in self.TYPE_DEFAULTS - - def is_valid(self): - if self.in_defaults(): - return True - storage = jms_storage.get_log_storage(self.config) - return storage.ping() - - def is_using(self): - return Terminal.objects.filter(command_storage=self.name).exists() - - -class ReplayStorage(CommonModelMixin): - TYPE_CHOICES = const.REPLAY_STORAGE_TYPE_CHOICES - TYPE_SERVER = const.REPLAY_STORAGE_TYPE_SERVER - TYPE_DEFAULTS = dict(const.REPLAY_STORAGE_TYPE_CHOICES_DEFAULT).keys() - - name = models.CharField(max_length=128, verbose_name=_("Name"), unique=True) - type = models.CharField( - max_length=16, choices=TYPE_CHOICES, verbose_name=_('Type'), - default=TYPE_SERVER - ) - meta = EncryptJsonDictTextField(default={}) - comment = models.TextField( - max_length=128, default='', blank=True, verbose_name=_('Comment') - ) - - def __str__(self): - return self.name - - def convert_type(self): - s3_type_list = [const.REPLAY_STORAGE_TYPE_CEPH] - tp = self.type - if tp in s3_type_list: - tp = const.REPLAY_STORAGE_TYPE_S3 - return tp - - def get_extra_config(self): - extra_config = {'TYPE': self.convert_type()} - if self.type == const.REPLAY_STORAGE_TYPE_SWIFT: - extra_config.update({'signer': 'S3SignerType'}) - return extra_config - - @property - def config(self): - config = self.meta - extra_config = self.get_extra_config() - config.update(extra_config) - return config - - def in_defaults(self): - return self.type in self.TYPE_DEFAULTS - - def is_valid(self): - if self.in_defaults(): - return True - storage = jms_storage.get_object_storage(self.config) - target = 'tests.py' - src = os.path.join(settings.BASE_DIR, 'common', target) - return storage.is_valid(src, target) - - def is_using(self): - return Terminal.objects.filter(replay_storage=self.name).exists() diff --git a/apps/terminal/models/__init__.py b/apps/terminal/models/__init__.py new file mode 100644 index 000000000..1de5fd31e --- /dev/null +++ b/apps/terminal/models/__init__.py @@ -0,0 +1,6 @@ +from .command import * +from .session import * +from .status import * +from .storage import * +from .task import * +from .terminal import * diff --git a/apps/terminal/models/command.py b/apps/terminal/models/command.py new file mode 100644 index 000000000..fba906226 --- /dev/null +++ b/apps/terminal/models/command.py @@ -0,0 +1,21 @@ +from __future__ import unicode_literals + +from django.db import models +from django.db.models.signals import post_save +from ..backends.command.models import AbstractSessionCommand + + +class CommandManager(models.Manager): + def bulk_create(self, objs, **kwargs): + resp = super().bulk_create(objs, **kwargs) + for i in objs: + post_save.send(i.__class__, instance=i, created=True) + return resp + + +class Command(AbstractSessionCommand): + objects = CommandManager() + + class Meta: + db_table = "terminal_command" + ordering = ('-timestamp',) diff --git a/apps/terminal/models/session.py b/apps/terminal/models/session.py new file mode 100644 index 000000000..4e2a1b99a --- /dev/null +++ b/apps/terminal/models/session.py @@ -0,0 +1,210 @@ +from __future__ import unicode_literals + +import os +import uuid + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone +from django.conf import settings +from django.core.files.storage import default_storage +from django.core.cache import cache + +from assets.models import Asset +from orgs.mixins.models import OrgModelMixin +from common.db.models import ChoiceSet +from ..backends import get_multi_command_storage +from .terminal import Terminal + + +class Session(OrgModelMixin): + class LOGIN_FROM(ChoiceSet): + ST = 'ST', 'SSH Terminal' + WT = 'WT', 'Web Terminal' + + class PROTOCOL(ChoiceSet): + SSH = 'ssh', 'ssh' + RDP = 'rdp', 'rdp' + VNC = 'vnc', 'vnc' + TELNET = 'telnet', 'telnet' + MYSQL = 'mysql', 'mysql' + ORACLE = 'oracle', 'oracle' + MARIADB = 'mariadb', 'mariadb' + POSTGRESQL = 'postgresql', 'postgresql' + K8S = 'k8s', 'kubernetes' + + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + user = models.CharField(max_length=128, verbose_name=_("User"), db_index=True) + user_id = models.CharField(blank=True, default='', max_length=36, db_index=True) + asset = models.CharField(max_length=128, verbose_name=_("Asset"), db_index=True) + asset_id = models.CharField(blank=True, default='', max_length=36, db_index=True) + system_user = models.CharField(max_length=128, verbose_name=_("System user"), db_index=True) + system_user_id = models.CharField(blank=True, default='', max_length=36, db_index=True) + login_from = models.CharField(max_length=2, choices=LOGIN_FROM.choices, default="ST", verbose_name=_("Login from")) + remote_addr = models.CharField(max_length=128, verbose_name=_("Remote addr"), blank=True, null=True) + is_success = models.BooleanField(default=True, db_index=True) + is_finished = models.BooleanField(default=False, db_index=True) + has_replay = models.BooleanField(default=False, verbose_name=_("Replay")) + has_command = models.BooleanField(default=False, verbose_name=_("Command")) + terminal = models.ForeignKey(Terminal, null=True, on_delete=models.DO_NOTHING, db_constraint=False) + protocol = models.CharField(choices=PROTOCOL.choices, default='ssh', max_length=16, db_index=True) + date_start = models.DateTimeField(verbose_name=_("Date start"), db_index=True, default=timezone.now) + date_end = models.DateTimeField(verbose_name=_("Date end"), null=True) + + upload_to = 'replay' + ACTIVE_CACHE_KEY_PREFIX = 'SESSION_ACTIVE_{}' + _DATE_START_FIRST_HAS_REPLAY_RDP_SESSION = None + + def get_rel_replay_path(self, version=2): + """ + 获取session日志的文件路径 + :param version: 原来后缀是 .gz,为了统一新版本改为 .replay.gz + :return: + """ + suffix = '.replay.gz' + if version == 1: + suffix = '.gz' + date = self.date_start.strftime('%Y-%m-%d') + return os.path.join(date, str(self.id) + suffix) + + def get_local_path(self, version=2): + rel_path = self.get_rel_replay_path(version=version) + if version == 2: + local_path = os.path.join(self.upload_to, rel_path) + else: + local_path = rel_path + return local_path + + @property + def asset_obj(self): + return Asset.objects.get(id=self.asset_id) + + @property + def _date_start_first_has_replay_rdp_session(self): + if self.__class__._DATE_START_FIRST_HAS_REPLAY_RDP_SESSION is None: + instance = self.__class__.objects.filter( + protocol='rdp', has_replay=True + ).order_by('date_start').first() + if not instance: + date_start = timezone.now() - timezone.timedelta(days=365) + else: + date_start = instance.date_start + self.__class__._DATE_START_FIRST_HAS_REPLAY_RDP_SESSION = date_start + return self.__class__._DATE_START_FIRST_HAS_REPLAY_RDP_SESSION + + def can_replay(self): + if self.has_replay: + return True + if self.date_start < self._date_start_first_has_replay_rdp_session: + return True + return False + + @property + def can_join(self): + _PROTOCOL = self.PROTOCOL + if self.is_finished: + return False + if self.protocol in [_PROTOCOL.SSH, _PROTOCOL.TELNET, _PROTOCOL.K8S]: + return True + else: + return False + + @property + def db_protocols(self): + _PROTOCOL = self.PROTOCOL + return [_PROTOCOL.MYSQL, _PROTOCOL.MARIADB, _PROTOCOL.ORACLE, _PROTOCOL.POSTGRESQL] + + @property + def can_terminate(self): + _PROTOCOL = self.PROTOCOL + if self.is_finished: + return False + if self.protocol in self.db_protocols: + return False + else: + return True + + def save_replay_to_storage(self, f): + local_path = self.get_local_path() + try: + name = default_storage.save(local_path, f) + except OSError as e: + return None, e + + if settings.SERVER_REPLAY_STORAGE: + from .tasks import upload_session_replay_to_external_storage + upload_session_replay_to_external_storage.delay(str(self.id)) + return name, None + + @classmethod + def set_sessions_active(cls, sessions_id): + data = {cls.ACTIVE_CACHE_KEY_PREFIX.format(i): i for i in sessions_id} + cache.set_many(data, timeout=5*60) + + @classmethod + def get_active_sessions(cls): + return cls.objects.filter(is_finished=False) + + def is_active(self): + if self.protocol in ['ssh', 'telnet', 'rdp', 'mysql']: + key = self.ACTIVE_CACHE_KEY_PREFIX.format(self.id) + return bool(cache.get(key)) + return True + + @property + def command_amount(self): + command_store = get_multi_command_storage() + return command_store.count(session=str(self.id)) + + @property + def login_from_display(self): + return self.get_login_from_display() + + @classmethod + def generate_fake(cls, count=100, is_finished=True): + import random + from orgs.models import Organization + from users.models import User + from assets.models import Asset, SystemUser + from orgs.utils import get_current_org + from common.utils.random import random_datetime, random_ip + + org = get_current_org() + if not org or not org.is_real(): + Organization.default().change_to() + i = 0 + users = User.objects.all()[:100] + assets = Asset.objects.all()[:100] + system_users = SystemUser.objects.all()[:100] + while i < count: + user_random = random.choices(users, k=10) + assets_random = random.choices(assets, k=10) + system_users = random.choices(system_users, k=10) + + ziped = zip(user_random, assets_random, system_users) + sessions = [] + now = timezone.now() + month_ago = now - timezone.timedelta(days=30) + for user, asset, system_user in ziped: + ip = random_ip() + date_start = random_datetime(month_ago, now) + date_end = random_datetime(date_start, date_start+timezone.timedelta(hours=2)) + data = dict( + user=str(user), user_id=user.id, + asset=str(asset), asset_id=asset.id, + system_user=str(system_user), system_user_id=system_user.id, + remote_addr=ip, + date_start=date_start, + date_end=date_end, + is_finished=is_finished, + ) + sessions.append(Session(**data)) + cls.objects.bulk_create(sessions) + i += 10 + + class Meta: + db_table = "terminal_session" + ordering = ["-date_start"] + + def __str__(self): + return "{0.id} of {0.user} to {0.asset}".format(self) diff --git a/apps/terminal/models/status.py b/apps/terminal/models/status.py new file mode 100644 index 000000000..a0607e5dc --- /dev/null +++ b/apps/terminal/models/status.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +import uuid + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from .terminal import Terminal + + +class Status(models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + session_online = models.IntegerField(verbose_name=_("Session Online"), default=0) + cpu_used = models.FloatField(verbose_name=_("CPU Usage")) + memory_used = models.FloatField(verbose_name=_("Memory Used")) + connections = models.IntegerField(verbose_name=_("Connections")) + threads = models.IntegerField(verbose_name=_("Threads")) + boot_time = models.FloatField(verbose_name=_("Boot Time")) + terminal = models.ForeignKey(Terminal, null=True, on_delete=models.CASCADE) + date_created = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'terminal_status' + get_latest_by = 'date_created' + + def __str__(self): + return self.date_created.strftime("%Y-%m-%d %H:%M:%S") + diff --git a/apps/terminal/models/storage.py b/apps/terminal/models/storage.py new file mode 100644 index 000000000..66fbe393d --- /dev/null +++ b/apps/terminal/models/storage.py @@ -0,0 +1,103 @@ +from __future__ import unicode_literals + +import os +import jms_storage + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + +from common.mixins import CommonModelMixin +from common.fields.model import EncryptJsonDictTextField +from .. import const +from .terminal import Terminal + + +class CommandStorage(CommonModelMixin): + TYPE_CHOICES = const.COMMAND_STORAGE_TYPE_CHOICES + TYPE_DEFAULTS = dict(const.REPLAY_STORAGE_TYPE_CHOICES_DEFAULT).keys() + TYPE_SERVER = const.COMMAND_STORAGE_TYPE_SERVER + + name = models.CharField(max_length=128, verbose_name=_("Name"), unique=True) + type = models.CharField( + max_length=16, choices=TYPE_CHOICES, verbose_name=_('Type'), + default=TYPE_SERVER + ) + meta = EncryptJsonDictTextField(default={}) + comment = models.TextField( + max_length=128, default='', blank=True, verbose_name=_('Comment') + ) + + def __str__(self): + return self.name + + @property + def config(self): + config = self.meta + config.update({'TYPE': self.type}) + return config + + def in_defaults(self): + return self.type in self.TYPE_DEFAULTS + + def is_valid(self): + if self.in_defaults(): + return True + storage = jms_storage.get_log_storage(self.config) + return storage.ping() + + def is_using(self): + return Terminal.objects.filter(command_storage=self.name).exists() + + +class ReplayStorage(CommonModelMixin): + TYPE_CHOICES = const.REPLAY_STORAGE_TYPE_CHOICES + TYPE_SERVER = const.REPLAY_STORAGE_TYPE_SERVER + TYPE_DEFAULTS = dict(const.REPLAY_STORAGE_TYPE_CHOICES_DEFAULT).keys() + + name = models.CharField(max_length=128, verbose_name=_("Name"), unique=True) + type = models.CharField( + max_length=16, choices=TYPE_CHOICES, verbose_name=_('Type'), + default=TYPE_SERVER + ) + meta = EncryptJsonDictTextField(default={}) + comment = models.TextField( + max_length=128, default='', blank=True, verbose_name=_('Comment') + ) + + def __str__(self): + return self.name + + def convert_type(self): + s3_type_list = [const.REPLAY_STORAGE_TYPE_CEPH] + tp = self.type + if tp in s3_type_list: + tp = const.REPLAY_STORAGE_TYPE_S3 + return tp + + def get_extra_config(self): + extra_config = {'TYPE': self.convert_type()} + if self.type == const.REPLAY_STORAGE_TYPE_SWIFT: + extra_config.update({'signer': 'S3SignerType'}) + return extra_config + + @property + def config(self): + config = self.meta + extra_config = self.get_extra_config() + config.update(extra_config) + return config + + def in_defaults(self): + return self.type in self.TYPE_DEFAULTS + + def is_valid(self): + if self.in_defaults(): + return True + storage = jms_storage.get_object_storage(self.config) + target = 'tests.py' + src = os.path.join(settings.BASE_DIR, 'common', target) + return storage.is_valid(src, target) + + def is_using(self): + return Terminal.objects.filter(replay_storage=self.name).exists() diff --git a/apps/terminal/models/task.py b/apps/terminal/models/task.py new file mode 100644 index 000000000..c863c9f77 --- /dev/null +++ b/apps/terminal/models/task.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals + +import uuid + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from .terminal import Terminal + + +class Task(models.Model): + NAME_CHOICES = ( + ("kill_session", "Kill Session"), + ) + + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + name = models.CharField(max_length=128, choices=NAME_CHOICES, verbose_name=_("Name")) + args = models.CharField(max_length=1024, verbose_name=_("Args")) + terminal = models.ForeignKey(Terminal, null=True, on_delete=models.SET_NULL) + is_finished = models.BooleanField(default=False) + date_created = models.DateTimeField(auto_now_add=True) + date_finished = models.DateTimeField(null=True) + + class Meta: + db_table = "terminal_task" + diff --git a/apps/terminal/models/terminal.py b/apps/terminal/models/terminal.py new file mode 100644 index 000000000..fb7a1dc24 --- /dev/null +++ b/apps/terminal/models/terminal.py @@ -0,0 +1,247 @@ +from __future__ import unicode_literals +import uuid + +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings +from django.core.cache import cache + +from users.models import User +from .. import const + + +class ComputeStatusMixin: + + # system status + @staticmethod + def _common_compute_system_status(value, thresholds): + if thresholds[0] <= value <= thresholds[1]: + return const.ComponentStatusChoices.normal.value + elif thresholds[1] < value <= thresholds[2]: + return const.ComponentStatusChoices.high.value + else: + return const.ComponentStatusChoices.critical.value + + def _compute_system_cpu_load_1_status(self, value): + thresholds = [0, 5, 20] + return self._common_compute_system_status(value, thresholds) + + def _compute_system_memory_used_percent_status(self, value): + thresholds = [0, 85, 95] + return self._common_compute_system_status(value, thresholds) + + def _compute_system_disk_used_percent_status(self, value): + thresholds = [0, 80, 99] + return self._common_compute_system_status(value, thresholds) + + def _compute_system_status(self, state): + system_status_keys = [ + 'system_cpu_load_1', 'system_memory_used_percent', 'system_disk_used_percent' + ] + system_status = [] + for system_status_key in system_status_keys: + state_value = state[system_status_key] + status = getattr(self, f'_compute_{system_status_key}_status')(state_value) + system_status.append(status) + return system_status + + def _compute_component_status(self, state): + system_status = self._compute_system_status(state) + if const.ComponentStatusChoices.critical in system_status: + return const.ComponentStatusChoices.critical + elif const.ComponentStatusChoices.high in system_status: + return const.ComponentStatusChoices.high + else: + return const.ComponentStatusChoices.normal + + @staticmethod + def _compute_component_status_display(status): + return getattr(const.ComponentStatusChoices, status).label + + +class TerminalStateMixin(ComputeStatusMixin): + CACHE_KEY_COMPONENT_STATE = 'CACHE_KEY_COMPONENT_STATE_TERMINAL_{}' + CACHE_TIMEOUT = 120 + + @property + def cache_key(self): + return self.CACHE_KEY_COMPONENT_STATE.format(str(self.id)) + + # get + def _get_from_cache(self): + return cache.get(self.cache_key) + + def _set_to_cache(self, state): + cache.set(self.cache_key, state, self.CACHE_TIMEOUT) + + # set + def _add_status(self, state): + status = self._compute_component_status(state) + status_display = self._compute_component_status_display(status) + state.update({ + 'status': status, + 'status_display': status_display + }) + + @property + def state(self): + state = self._get_from_cache() + return state or {} + + @state.setter + def state(self, state): + self._add_status(state) + self._set_to_cache(state) + + +class TerminalStatusMixin(TerminalStateMixin): + + # alive + @property + def is_alive(self): + return bool(self.state) + + # status + @property + def status(self): + if self.is_alive: + return self.state['status'] + else: + return const.ComponentStatusChoices.critical.value + + @property + def status_display(self): + return self._compute_component_status_display(self.status) + + @property + def is_normal(self): + return self.status == const.ComponentStatusChoices.normal.value + + @property + def is_high(self): + return self.status == const.ComponentStatusChoices.high.value + + @property + def is_critical(self): + return self.status == const.ComponentStatusChoices.critical.value + + +class Terminal(TerminalStatusMixin, models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + name = models.CharField(max_length=128, verbose_name=_('Name')) + type = models.CharField(choices=const.TerminalTypeChoices.choices, max_length=64, verbose_name=_('type')) + remote_addr = models.CharField(max_length=128, blank=True, verbose_name=_('Remote Address')) + ssh_port = models.IntegerField(verbose_name=_('SSH Port'), default=2222) + http_port = models.IntegerField(verbose_name=_('HTTP Port'), default=5000) + command_storage = models.CharField(max_length=128, verbose_name=_("Command storage"), default='default') + replay_storage = models.CharField(max_length=128, verbose_name=_("Replay storage"), default='default') + user = models.OneToOneField(User, related_name='terminal', verbose_name='Application User', null=True, on_delete=models.CASCADE) + is_accepted = models.BooleanField(default=False, verbose_name='Is Accepted') + is_deleted = models.BooleanField(default=False) + date_created = models.DateTimeField(auto_now_add=True) + comment = models.TextField(blank=True, verbose_name=_('Comment')) + + @property + def is_active(self): + if self.user and self.user.is_active: + return True + return False + + @is_active.setter + def is_active(self, active): + if self.user: + self.user.is_active = active + self.user.save() + + def get_command_storage(self): + from .storage import CommandStorage + storage = CommandStorage.objects.filter(name=self.command_storage).first() + return storage + + def get_command_storage_config(self): + s = self.get_command_storage() + if s: + config = s.config + else: + config = settings.DEFAULT_TERMINAL_COMMAND_STORAGE + return config + + def get_command_storage_setting(self): + config = self.get_command_storage_config() + return {"TERMINAL_COMMAND_STORAGE": config} + + def get_replay_storage(self): + from .storage import ReplayStorage + storage = ReplayStorage.objects.filter(name=self.replay_storage).first() + return storage + + def get_replay_storage_config(self): + s = self.get_replay_storage() + if s: + config = s.config + else: + config = settings.DEFAULT_TERMINAL_REPLAY_STORAGE + return config + + def get_replay_storage_setting(self): + config = self.get_replay_storage_config() + return {"TERMINAL_REPLAY_STORAGE": config} + + @staticmethod + def get_login_title_setting(): + login_title = None + if settings.XPACK_ENABLED: + from xpack.plugins.interface.models import Interface + login_title = Interface.get_login_title() + return {'TERMINAL_HEADER_TITLE': login_title} + + @property + def config(self): + configs = {} + for k in dir(settings): + if not k.startswith('TERMINAL'): + continue + configs[k] = getattr(settings, k) + configs.update(self.get_command_storage_setting()) + configs.update(self.get_replay_storage_setting()) + configs.update(self.get_login_title_setting()) + configs.update({ + 'SECURITY_MAX_IDLE_TIME': settings.SECURITY_MAX_IDLE_TIME + }) + return configs + + @property + def service_account(self): + return self.user + + def create_app_user(self): + random = uuid.uuid4().hex[:6] + user, access_key = User.create_app_user( + name="{}-{}".format(self.name, random), comment=self.comment + ) + self.user = user + self.save() + return user, access_key + + def delete(self, using=None, keep_parents=False): + if self.user: + self.user.delete() + self.user = None + self.is_deleted = True + self.save() + return + + def __str__(self): + status = "Active" + if not self.is_accepted: + status = "NotAccept" + elif self.is_deleted: + status = "Deleted" + elif not self.is_active: + status = "Disable" + return '%s: %s' % (self.name, status) + + class Meta: + ordering = ('is_accepted',) + db_table = "terminal" + diff --git a/apps/terminal/serializers/__init__.py b/apps/terminal/serializers/__init__.py index f1714dc21..e958d7955 100644 --- a/apps/terminal/serializers/__init__.py +++ b/apps/terminal/serializers/__init__.py @@ -4,3 +4,4 @@ from .terminal import * from .session import * from .storage import * from .command import * +from .components import * diff --git a/apps/terminal/serializers/components.py b/apps/terminal/serializers/components.py new file mode 100644 index 000000000..e557d8f4f --- /dev/null +++ b/apps/terminal/serializers/components.py @@ -0,0 +1,25 @@ + +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ + + +class ComponentsStateSerializer(serializers.Serializer): + # system + system_cpu_load_1 = serializers.FloatField( + required=False, default=0, label=_("System cpu load (1 minutes)") + ) + system_memory_used_percent = serializers.FloatField( + required=False, default=0, label=_('System memory used percent') + ) + system_disk_used_percent = serializers.FloatField( + required=False, default=0, label=_('System disk used percent') + ) + # sessions + session_active_count = serializers.IntegerField( + required=False, default=0, label=_("Session active count") + ) + + def save(self, **kwargs): + request = self.context['request'] + terminal = request.user.terminal + terminal.state = self.validated_data diff --git a/apps/terminal/serializers/terminal.py b/apps/terminal/serializers/terminal.py index 896c44440..ef285c869 100644 --- a/apps/terminal/serializers/terminal.py +++ b/apps/terminal/serializers/terminal.py @@ -6,19 +6,25 @@ from common.utils import is_uuid from ..models import ( Terminal, Status, Session, Task, CommandStorage, ReplayStorage ) +from .components import ComponentsStateSerializer class TerminalSerializer(BulkModelSerializer): session_online = serializers.SerializerMethodField() is_alive = serializers.BooleanField(read_only=True) + status = serializers.CharField(read_only=True) + status_display = serializers.CharField(read_only=True) + state = ComponentsStateSerializer(read_only=True) class Meta: model = Terminal fields = [ - 'id', 'name', 'remote_addr', 'http_port', 'ssh_port', + 'id', 'name', 'type', 'remote_addr', 'http_port', 'ssh_port', 'comment', 'is_accepted', "is_active", 'session_online', - 'is_alive', 'date_created', 'command_storage', 'replay_storage' + 'is_alive', 'date_created', 'command_storage', 'replay_storage', + 'status', 'status_display', 'state' ] + read_only_fields = ['type', 'date_created'] @staticmethod def get_kwargs_may_be_uuid(value): diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index d1c860f03..5a8efe8e8 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -33,7 +33,10 @@ urlpatterns = [ path('commands/export/', api.CommandExportApi.as_view(), name="command-export"), path('commands/insecure-command/', api.InsecureCommandAlertAPI.as_view(), name="command-alert"), path('replay-storages/<uuid:pk>/test-connective/', api.ReplayStorageTestConnectiveApi.as_view(), name='replay-storage-test-connective'), - path('command-storages/<uuid:pk>/test-connective/', api.CommandStorageTestConnectiveApi.as_view(), name='command-storage-test-connective') + path('command-storages/<uuid:pk>/test-connective/', api.CommandStorageTestConnectiveApi.as_view(), name='command-storage-test-connective'), + # components + path('components/metrics/', api.ComponentsMetricsAPIView.as_view(), name='components-metrics'), + path('components/state/', api.ComponentsStateAPIView.as_view(), name='components-state'), # v2: get session's replay # path('v2/sessions/<uuid:pk>/replay/', # api.SessionReplayV2ViewSet.as_view({'get': 'retrieve'}), diff --git a/apps/terminal/utils.py b/apps/terminal/utils.py index 9c87695d0..68456dfbe 100644 --- a/apps/terminal/utils.py +++ b/apps/terminal/utils.py @@ -11,6 +11,7 @@ import jms_storage from common.tasks import send_mail_async from common.utils import get_logger, reverse from settings.models import Setting +from . import const from .models import ReplayStorage, Session, Command @@ -101,3 +102,104 @@ def send_command_alert_mail(command): logger.debug(message) send_mail_async.delay(subject, message, recipient_list, html_message=message) + + +class ComponentsMetricsUtil(object): + + def __init__(self, component_type=None): + self.type = component_type + self.components = [] + self.initial_components() + + def initial_components(self): + from .models import Terminal + terminals = Terminal.objects.all().order_by('type') + if self.type: + terminals = terminals.filter(type=self.type) + self.components = list(terminals) + + def get_metrics(self): + total_count = normal_count = high_count = critical_count = session_active_total = 0 + for component in self.components: + total_count += 1 + if not component.is_alive: + critical_count += 1 + continue + session_active_total += component.state.get('session_active_count', 0) + if component.is_normal: + normal_count += 1 + elif component.is_high: + high_count += 1 + else: + critical_count += 1 + metrics = { + 'total': total_count, + 'normal': normal_count, + 'high': high_count, + 'critical': critical_count, + 'session_active': session_active_total + } + return metrics + + +class ComponentsPrometheusMetricsUtil(ComponentsMetricsUtil): + + @staticmethod + def get_status_metrics(metrics): + return { + 'any': metrics['total'], + 'normal': metrics['normal'], + 'high': metrics['high'], + 'critical': metrics['critical'] + } + + def get_prometheus_metrics_text(self): + prometheus_metrics = [] + prometheus_metrics.append('# JumpServer 各组件状态个数汇总') + base_status_metric_text = 'jumpserver_components_status_total{component_type="%s", status="%s"} %s' + for component in self.components: + component_type = component.type + base_metrics = self.get_metrics() + + prometheus_metrics.append(f'## 组件: {component_type}') + status_metrics = self.get_status_metrics(base_metrics) + for status, value in status_metrics.items(): + metric_text = base_status_metric_text % (component_type, status, value) + prometheus_metrics.append(metric_text) + + prometheus_metrics.append('\n') + prometheus_metrics.append('# JumpServer 各组件在线会话数汇总') + base_session_active_metric_text = 'jumpserver_components_session_active_total{component_type="%s"} %s' + for component in self.components: + component_type = component.type + prometheus_metrics.append(f'## 组件: {component_type}') + base_metrics = self.get_metrics() + metric_text = base_session_active_metric_text % ( + component_type, + base_metrics['session_active'] + ) + prometheus_metrics.append(metric_text) + + prometheus_metrics.append('\n') + prometheus_metrics.append('# JumpServer 各组件节点一些指标') + base_system_state_metric_text = 'jumpserver_components_%s{component_type="%s", component="%s"} %s' + system_states_name = [ + 'system_cpu_load_1', 'system_memory_used_percent', + 'system_disk_used_percent', 'session_active_count' + ] + for system_state_name in system_states_name: + prometheus_metrics.append(f'## 指标: {system_state_name}') + for component in self.components: + if not component.is_alive: + continue + component_type = component.type + metric_text = base_system_state_metric_text % ( + system_state_name, + component_type, + component.name, + component.state.get(system_state_name) + ) + prometheus_metrics.append(metric_text) + + prometheus_metrics_text = '\n'.join(prometheus_metrics) + return prometheus_metrics_text diff --git a/apps/users/api/user.py b/apps/users/api/user.py index 65bd8f4d1..b0b209e19 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -6,8 +6,8 @@ from rest_framework.decorators import action from rest_framework import generics from rest_framework.response import Response from rest_framework_bulk import BulkModelViewSet +from django.db.models import Prefetch -from common.db.aggregates import GroupConcat from common.permissions import ( IsOrgAdmin, IsOrgAdminOrAppUser, CanUpdateDeleteUser, IsSuperUser @@ -44,9 +44,18 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): extra_filter_backends = [OrgRoleUserFilterBackend] def get_queryset(self): - return super().get_queryset().annotate( - gc_m2m_org_members__role=GroupConcat('m2m_org_members__role'), - ).prefetch_related('groups') + queryset = super().get_queryset().prefetch_related( + 'groups' + ) + if current_org.is_real(): + # 为在列表中计算用户在真实组织里的角色 + queryset = queryset.prefetch_related( + Prefetch( + 'm2m_org_members', + queryset=OrganizationMember.objects.filter(org__id=current_org.id) + ) + ) + return queryset def send_created_signal(self, users): if not isinstance(users, list): diff --git a/apps/users/forms/user.py b/apps/users/forms/user.py index f3852c0dd..299862b5e 100644 --- a/apps/users/forms/user.py +++ b/apps/users/forms/user.py @@ -28,7 +28,7 @@ class UserCreateUpdateFormMixin(OrgModelForm): ) source = forms.ChoiceField( choices=get_source_choices, required=True, - initial=User.SOURCE_LOCAL, label=_("Source") + initial=User.Source.local.value, label=_("Source") ) public_key = forms.CharField( label=_('ssh public key'), max_length=5000, required=False, diff --git a/apps/users/migrations/0031_auto_20201118_1801.py b/apps/users/migrations/0031_auto_20201118_1801.py new file mode 100644 index 000000000..6c0f22303 --- /dev/null +++ b/apps/users/migrations/0031_auto_20201118_1801.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-11-18 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0030_auto_20200819_2041'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + ] diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 81f0f593b..2dcbd452d 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -7,10 +7,10 @@ import string import random from django.conf import settings -from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser from django.core.cache import cache from django.db import models +from django.db.models import TextChoices from django.utils.translation import ugettext_lazy as _ from django.utils import timezone @@ -170,22 +170,18 @@ class RoleMixin: from orgs.models import ROLE as ORG_ROLE if not current_org.is_real(): + # 不是真实的组织,取 User 本身的角色 if self.is_superuser: return [ORG_ROLE.ADMIN] else: return [ORG_ROLE.USER] - if hasattr(self, 'gc_m2m_org_members__role'): - names = self.gc_m2m_org_members__role - if isinstance(names, str): - roles = set(self.gc_m2m_org_members__role.split(',')) - else: - roles = set() - else: - roles = set(self.m2m_org_members.filter( - org_id=current_org.id - ).values_list('role', flat=True)) - roles = list(roles) + # 是真实组织,取 OrganizationMember 中的角色 + roles = [ + org_member.role + for org_member in self.m2m_org_members.all() + if org_member.org_id == current_org.id + ] roles.sort() return roles @@ -485,18 +481,12 @@ class MFAMixin: class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): - SOURCE_LOCAL = 'local' - SOURCE_LDAP = 'ldap' - SOURCE_OPENID = 'openid' - SOURCE_RADIUS = 'radius' - SOURCE_CAS = 'cas' - SOURCE_CHOICES = ( - (SOURCE_LOCAL, _('Local')), - (SOURCE_LDAP, 'LDAP/AD'), - (SOURCE_OPENID, 'OpenID'), - (SOURCE_RADIUS, 'Radius'), - (SOURCE_CAS, 'CAS'), - ) + class Source(TextChoices): + local = 'local', _('Local') + ldap = 'ldap', 'LDAP/AD' + openid = 'openid', 'OpenID' + radius = 'radius', 'Radius' + cas = 'cas', 'CAS' id = models.UUIDField(default=uuid.uuid4, primary_key=True) username = models.CharField( @@ -546,7 +536,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): max_length=30, default='', blank=True, verbose_name=_('Created by') ) source = models.CharField( - max_length=30, default=SOURCE_LOCAL, choices=SOURCE_CHOICES, + max_length=30, default=Source.local.value, choices=Source.choices, verbose_name=_('Source') ) date_password_last_updated = models.DateTimeField( @@ -597,7 +587,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): @property def is_local(self): - return self.source == self.SOURCE_LOCAL + return self.source == self.Source.local.value def set_unprovide_attr_if_need(self): if not self.name: @@ -667,6 +657,6 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): user.groups.add(UserGroup.initial()) def can_send_created_mail(self): - if self.email and self.source == self.SOURCE_LOCAL: + if self.email and self.source == self.Source.local.value: return True return False diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index e59467173..1e50c1107 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -68,6 +68,10 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer): 'can_update', 'can_delete', 'login_blocked', 'org_roles' ] + read_only_fields = [ + 'date_joined', 'last_login', 'created_by', 'is_first_login', 'source' + ] + extra_kwargs = { 'password': {'write_only': True, 'required': False, 'allow_null': True, 'allow_blank': True}, 'public_key': {'write_only': True}, diff --git a/apps/users/signals_handler.py b/apps/users/signals_handler.py index 3fdf6ddc6..09320a2e1 100644 --- a/apps/users/signals_handler.py +++ b/apps/users/signals_handler.py @@ -2,14 +2,12 @@ # from django.dispatch import receiver -from django.db.models.signals import m2m_changed from django_auth_ldap.backend import populate_user from django.conf import settings from django_cas_ng.signals import cas_user_authenticated from jms_oidc_rp.signals import openid_create_or_update_user -from perms.tasks import create_rebuild_user_tree_task from common.utils import get_logger from .signals import post_user_create from .models import User @@ -27,19 +25,10 @@ def on_user_create(sender, user=None, **kwargs): send_user_created_mail(user) -@receiver(m2m_changed, sender=User.groups.through) -def on_user_groups_change(instance, action, reverse, pk_set, **kwargs): - if action.startswith('post'): - if reverse: - create_rebuild_user_tree_task(pk_set) - else: - create_rebuild_user_tree_task([instance.id]) - - @receiver(cas_user_authenticated) def on_cas_user_authenticated(sender, user, created, **kwargs): if created: - user.source = user.SOURCE_CAS + user.source = user.Source.cas.value user.save() @@ -48,7 +37,7 @@ def on_ldap_create_user(sender, user, ldap_user, **kwargs): if user and user.username not in ['admin']: exists = User.objects.filter(username=user.username).exists() if not exists: - user.source = user.SOURCE_LDAP + user.source = user.Source.ldap.value user.save() @@ -57,9 +46,9 @@ def on_openid_create_or_update_user(sender, request, user, created, name, userna if created: logger.debug( "Receive OpenID user created signal: {}, " - "Set user source is: {}".format(user, User.SOURCE_OPENID) + "Set user source is: {}".format(user, User.Source.openid.value) ) - user.source = User.SOURCE_OPENID + user.source = User.Source.openid.value user.save() elif not created and settings.AUTH_OPENID_ALWAYS_UPDATE_USER: logger.debug( diff --git a/apps/users/tasks.py b/apps/users/tasks.py index f575a3afc..fc938499b 100644 --- a/apps/users/tasks.py +++ b/apps/users/tasks.py @@ -22,7 +22,7 @@ logger = get_logger(__file__) @shared_task def check_password_expired(): - users = User.objects.filter(source=User.SOURCE_LOCAL).exclude(role=User.ROLE.APP) + users = User.objects.filter(source=User.Source.local.value).exclude(role=User.ROLE.APP) for user in users: if not user.is_valid: continue diff --git a/apps/users/utils.py b/apps/users/utils.py index be33f25ba..94669c03a 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -362,18 +362,17 @@ def get_current_org_members(exclude=()): def get_source_choices(): from .models import User - choices_all = dict(User.SOURCE_CHOICES) choices = [ - (User.SOURCE_LOCAL, choices_all[User.SOURCE_LOCAL]), + (User.Source.local.value, User.Source.local.label), ] if settings.AUTH_LDAP: - choices.append((User.SOURCE_LDAP, choices_all[User.SOURCE_LDAP])) + choices.append((User.Source.ldap.value, User.Source.ldap.label)) if settings.AUTH_OPENID: - choices.append((User.SOURCE_OPENID, choices_all[User.SOURCE_OPENID])) + choices.append((User.Source.openid.value, User.Source.openid.label)) if settings.AUTH_RADIUS: - choices.append((User.SOURCE_RADIUS, choices_all[User.SOURCE_RADIUS])) + choices.append((User.Source.radius.value, User.Source.radius.label)) if settings.AUTH_CAS: - choices.append((User.SOURCE_CAS, choices_all[User.SOURCE_CAS])) + choices.append((User.Source.cas.value, User.Source.cas.label)) return choices diff --git a/entrypoint.sh b/entrypoint.sh index f509203d4..fa798f642 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,15 +7,13 @@ function cleanup() fi } -service="all" -if [[ "$1" != "" ]];then - service=$1 -fi +action="${1-start}" +service="${2-all}" trap cleanup EXIT -if [[ "$1" == "bash" ]];then +if [[ "$action" == "bash" || "$action" == "sh" ]];then bash else - python jms start ${service} + python jms "${action}" "${service}" fi diff --git a/jms b/jms index 3969b5418..7e4f2faed 100755 --- a/jms +++ b/jms @@ -81,11 +81,23 @@ def check_database_connection(): logging.info("Database connect success") return time.sleep(1) - logging.info("Connection database failed, exist") + logging.error("Connection database failed, exist") sys.exit(10) -def make_migrations(): +def check_migrations(): + apps_dir = os.path.join(BASE_DIR, 'apps') + code = subprocess.call("python manage.py showmigrations | grep '\[.\]' | grep -v '\[X\]'", shell=True, cwd=apps_dir) + + if code == 1: + return + # for i in range(3): + # print("!!! Warning: Has SQL migrations not perform, 有 SQL 变更没有执行") + # print("You should run `./PROC upgrade_db` first, 请先运行 ./PROC upgrade_db, 进行表结构变更") + # sys.exit(1) + + +def perform_db_migrate(): logging.info("Check database structure change ...") os.chdir(os.path.join(BASE_DIR, 'apps')) logging.info("Migrate model change to database ...") @@ -102,8 +114,8 @@ def collect_static(): def prepare(): check_database_connection() - make_migrations() - collect_static() + check_migrations() + upgrade_db() def check_pid(pid): @@ -156,7 +168,10 @@ def is_running(s, unlink=True): def parse_service(s): web_services = ['gunicorn', 'flower', 'daphne'] - celery_services = ["celery_ansible", "celery_default", "celery_node_tree", "check_asset_perm_expired"] + celery_services = [ + "celery_ansible", "celery_default", "celery_node_tree", + "celery_check_asset_perm_expired", "celery_heavy_tasks" + ] task_services = celery_services + ['beat'] all_services = web_services + task_services if s == 'all': @@ -225,9 +240,14 @@ def get_start_celery_node_tree_kwargs(): return get_start_worker_kwargs('node_tree', 2) +def get_start_celery_heavy_tasks_kwargs(): + print("\n- Start Celery as Distributed Task Queue: HeavyTasks") + return get_start_worker_kwargs('celery_heavy_tasks', 1) + + def get_start_celery_check_asset_perm_expired_kwargs(): print("\n- Start Celery as Distributed Task Queue: CheckAseetPermissionExpired") - return get_start_worker_kwargs('check_asset_perm_expired', 1) + return get_start_worker_kwargs('celery_check_asset_perm_expired', 1) def get_start_worker_kwargs(queue, num): @@ -366,7 +386,8 @@ def start_service(s): "celery_ansible": get_start_celery_ansible_kwargs, "celery_default": get_start_celery_default_kwargs, "celery_node_tree": get_start_celery_node_tree_kwargs, - "check_asset_perm_expired": get_start_celery_check_asset_perm_expired_kwargs, + "celery_heavy_tasks": get_start_celery_heavy_tasks_kwargs, + "celery_check_asset_perm_expired": get_start_celery_check_asset_perm_expired_kwargs, "beat": get_start_beat_kwargs, "flower": get_start_flower_kwargs, "daphne": get_start_daphne_kwargs, @@ -503,6 +524,11 @@ def show_service_status(s): print("{} is stopped".format(ns)) +def upgrade_db(): + collect_static() + perform_db_migrate() + + if __name__ == '__main__': parser = argparse.ArgumentParser( description=""" @@ -515,7 +541,7 @@ if __name__ == '__main__': ) parser.add_argument( 'action', type=str, - choices=("start", "stop", "restart", "status"), + choices=("start", "stop", "restart", "status", "upgrade_db"), help="Action to run" ) parser.add_argument( @@ -550,5 +576,7 @@ if __name__ == '__main__': stop_service(srv) time.sleep(5) start_services_and_watch(srv) + elif action == "upgrade_db": + upgrade_db() else: show_service_status(srv) diff --git a/requirements/deb_buster_requirements.txt b/requirements/deb_buster_requirements.txt new file mode 100644 index 000000000..50bde94aa --- /dev/null +++ b/requirements/deb_buster_requirements.txt @@ -0,0 +1,37 @@ +# common +gcc +cmake +curl +wget +vim +locales + +# mysql-client +default-libmysqlclient-dev + +# Pillow +# libffi-dev +# libfreetype6-dev +# libfribidi-dev +# libharfbuzz-dev +# libjpeg-turbo-progs +# libjpeg62-turbo-dev +# liblcms2-dev +# libopenjp2-7-dev +# libtiff5-dev +# libwebp-dev +# python3-tk +# zlib1g-dev + + +# ldap +openssl +libssl-dev +libldap2-dev +libsasl2-dev +libkrb5-dev +sqlite + +# ansible +sshpass + diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 8fbaf4cf7..b635353de 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -12,20 +12,20 @@ chardet==3.0.4 configparser==3.5.0 coreapi==2.3.3 coreschema==0.0.4 -cryptography==2.8 +cryptography==3.2 decorator==4.1.2 -Django==2.2.13 -django-auth-ldap==1.7.0 -django-bootstrap3==9.1.0 +Django==3.1 +django-auth-ldap==2.2.0 +django-bootstrap3==14.2.0 django-celery-beat==2.0 -django-filter==2.0.0 -django-formtools==2.1 +django-filter==2.4.0 +django-formtools==2.2 django-ranged-response==0.2.0 django-redis-cache==2.1.1 -django-rest-swagger==2.1.2 -django-simple-captcha==0.5.6 +django-rest-swagger==2.2.0 +django-simple-captcha==0.5.13 django-timezone-field==4.0 -djangorestframework==3.9.4 +djangorestframework==3.12.2 djangorestframework-bulk==0.2.1 docutils==0.14 ecdsa==0.13.3 @@ -44,7 +44,7 @@ jmespath==0.9.3 kombu==4.6.8 ldap3==2.4 MarkupSafe==1.1.1 -mysqlclient==1.3.14 +mysqlclient==2.0.1 olefile==0.44 openapi-codec==1.3.2 paramiko==2.4.2 @@ -53,10 +53,12 @@ Pillow==7.1.0 pyasn1==0.4.8 pycparser==2.19 pycrypto==2.6.1 +pycryptodome==3.9.9 +pycryptodomex==3.9.9 pyotp==2.2.6 PyNaCl==1.2.1 python-dateutil==2.6.1 -python-gssapi==0.6.4 +#python-gssapi==0.6.4 pytz==2018.3 PyYAML==5.1 redis==3.5.3 @@ -69,7 +71,7 @@ sshpubkeys==3.1.0 uritemplate==3.0.0 urllib3==1.25.2 vine==1.3.0 -drf-yasg==1.9.1 +drf-yasg==1.20.0 Werkzeug==0.15.3 drf-nested-routers==0.91 aliyun-python-sdk-core-v3==2.9.1 @@ -86,9 +88,9 @@ httpsig==1.3.0 treelib==1.5.3 django-proxy==1.2.1 flower==0.9.3 -channels-redis==2.4.0 -channels==2.3.0 -daphne==2.3.0 +channels-redis==3.2.0 +channels==2.4.0 +daphne==2.4.1 psutil==5.6.6 django-cas-ng==4.0.1 python-cas==1.5.0 @@ -103,3 +105,6 @@ azure-mgmt-compute==4.6.2 azure-mgmt-network==2.7.0 msrestazure==0.6.4 adal==1.2.5 +openpyxl==3.0.5 +pyexcel==0.6.6 +pyexcel-xlsx==0.6.0 diff --git a/requirements/rpm_requirements.txt b/requirements/rpm_requirements.txt index 2e02f07bd..0501d2860 100644 --- a/requirements/rpm_requirements.txt +++ b/requirements/rpm_requirements.txt @@ -1 +1 @@ -gcc krb5-devel libtiff-devel libjpeg-devel libzip-devel freetype-devel lcms2-devel libwebp-devel tcl-devel tk-devel sshpass openldap-devel mariadb-devel mysql-devel mysql libffi-devel openssh-clients telnet openldap-clients +gcc make krb5-devel libtiff-devel libjpeg-devel libzip-devel freetype-devel lcms2-devel libwebp-devel tcl-devel tk-devel sshpass openldap-devel mariadb-devel mysql-community-devel mysql libffi-devel openssh-clients telnet openldap-clients diff --git a/utils/generate_fake_data/generate.py b/utils/generate_fake_data/generate.py index f504c8e55..c87a8dbe4 100644 --- a/utils/generate_fake_data/generate.py +++ b/utils/generate_fake_data/generate.py @@ -15,6 +15,7 @@ django.setup() from resources.assets import AssetsGenerator, NodesGenerator, SystemUsersGenerator, AdminUsersGenerator from resources.users import UserGroupGenerator, UserGenerator from resources.perms import AssetPermissionGenerator +from resources.system import StatGenerator resource_generator_mapper = { @@ -24,7 +25,8 @@ resource_generator_mapper = { 'admin_user': AdminUsersGenerator, 'user': UserGenerator, 'user_group': UserGroupGenerator, - 'asset_permission': AssetPermissionGenerator + 'asset_permission': AssetPermissionGenerator, + 'stat': StatGenerator } diff --git a/utils/generate_fake_data/resources/system.py b/utils/generate_fake_data/resources/system.py new file mode 100644 index 000000000..69a12d144 --- /dev/null +++ b/utils/generate_fake_data/resources/system.py @@ -0,0 +1,69 @@ +import random + +from .base import FakeDataGenerator +from system.models import * + + +class StatGenerator(FakeDataGenerator): + resource = 'stat' + + nodes = [ + { + 'node': 'guacamole-01', + 'ip': '192.168.1.1', + 'component': 'guacamole' + }, + { + 'node': 'koko-01', + 'ip': '192.168.1.2', + 'component': 'koko' + }, + { + 'node': 'omnidb-01', + 'ip': '192.168.1.3', + 'component': 'omnidb' + }, + { + 'node': 'core-01', + 'ip': '192.168.1.4', + 'component': 'core' + } + ] + items_value_range = { + 'cpu_load': (0, 3.0), + 'memory_used_percent': (20, 10.0), + 'disk_used_percent': (30, 80.0), + 'thread': (100, 100), + 'goroutine': (200, 500), + 'replay_upload_health': (0, [0, 1]), + 'command_upload_health': (0, [0, 1]), + 'session_active': (100, 50), + 'session_processed': (400, 400), + 'session_failed': (50, 100), + 'session_succeeded': (500, 300) + } + + def do_generate(self, batch, batch_size): + datetime = timezone.now() + for i in batch: + datetime = datetime - timezone.timedelta(minutes=1) + items = [] + for node in self.nodes: + for key, values in self.items_value_range.items(): + base, r = values + if isinstance(r, int): + value = int(random.random() * r) + elif isinstance(r, float): + value = round(random.random() * r, 2) + elif isinstance(r, list): + value = random.choice(r) + else: + continue + value += base + node.update({ + 'key': key, + 'value': value, + 'datetime': datetime + }) + items.append(Stat(**node)) + Stat.objects.bulk_create(items, ignore_conflicts=True) \ No newline at end of file diff --git a/utils/generate_fake_data/resources/users.py b/utils/generate_fake_data/resources/users.py index 05332e4e2..0d8cbab9a 100644 --- a/utils/generate_fake_data/resources/users.py +++ b/utils/generate_fake_data/resources/users.py @@ -47,7 +47,7 @@ class UserGenerator(FakeDataGenerator): def do_generate(self, batch, batch_size): users = [] for i in batch: - username = forgery_py.internet.user_name(True) + username = forgery_py.internet.user_name(True) + '-' + str(i) email = forgery_py.internet.email_address() u = User( username=username,