diff --git a/.gitignore b/.gitignore index 5d5eb57db..ecbb47960 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ logs/* .vagrant/ release/* releashe +/apps/script.py diff --git a/apps/acls/models/login_acl.py b/apps/acls/models/login_acl.py index 323599f4c..03fa1ef7a 100644 --- a/apps/acls/models/login_acl.py +++ b/apps/acls/models/login_acl.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from .base import BaseACL, BaseACLQuerySet -from ..utils import contains_ip +from common.utils.ip import contains_ip class ACLManager(models.Manager): diff --git a/apps/acls/models/login_asset_acl.py b/apps/acls/models/login_asset_acl.py index 8ac140370..d61e7ae23 100644 --- a/apps/acls/models/login_asset_acl.py +++ b/apps/acls/models/login_asset_acl.py @@ -3,7 +3,7 @@ from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from orgs.mixins.models import OrgModelMixin, OrgManager from .base import BaseACL, BaseACLQuerySet -from ..utils import contains_ip +from common.utils.ip import contains_ip class ACLManager(OrgManager): diff --git a/apps/acls/serializers/login_acl.py b/apps/acls/serializers/login_acl.py index b303db533..c1b21f114 100644 --- a/apps/acls/serializers/login_acl.py +++ b/apps/acls/serializers/login_acl.py @@ -3,7 +3,7 @@ from rest_framework import serializers from common.drf.serializers import BulkModelSerializer from orgs.utils import current_org from ..models import LoginACL -from ..utils import is_ip_address, is_ip_network, is_ip_segment +from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment __all__ = ['LoginACLSerializer', ] diff --git a/apps/acls/utils.py b/apps/acls/utils.py index 0000a5da7..e69de29bb 100644 --- a/apps/acls/utils.py +++ b/apps/acls/utils.py @@ -1,68 +0,0 @@ -from ipaddress import ip_network, ip_address - - -def is_ip_address(address): - """ 192.168.10.1 """ - try: - ip_address(address) - except ValueError: - return False - else: - return True - - -def is_ip_network(ip): - """ 192.168.1.0/24 """ - try: - ip_network(ip) - except ValueError: - return False - else: - return True - - -def is_ip_segment(ip): - """ 10.1.1.1-10.1.1.20 """ - if '-' not in ip: - return False - ip_address1, ip_address2 = ip.split('-') - return is_ip_address(ip_address1) and is_ip_address(ip_address2) - - -def in_ip_segment(ip, ip_segment): - ip1, ip2 = ip_segment.split('-') - ip1 = int(ip_address(ip1)) - ip2 = int(ip_address(ip2)) - ip = int(ip_address(ip)) - return min(ip1, ip2) <= ip <= max(ip1, ip2) - - -def contains_ip(ip, ip_group): - """ - ip_group: - [192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64.] - - """ - - if '*' in ip_group: - return True - - for _ip in ip_group: - if is_ip_address(_ip): - # 192.168.10.1 - if ip == _ip: - return True - elif is_ip_network(_ip) and is_ip_address(ip): - # 192.168.1.0/24 - if ip_address(ip) in ip_network(_ip): - return True - elif is_ip_segment(_ip) and is_ip_address(ip): - # 10.1.1.1-10.1.1.20 - if in_ip_segment(ip, _ip): - return True - else: - # is domain name - if ip == _ip: - return True - - return False diff --git a/apps/applications/api/__init__.py b/apps/applications/api/__init__.py index a11b4966a..3258614fd 100644 --- a/apps/applications/api/__init__.py +++ b/apps/applications/api/__init__.py @@ -1,4 +1,4 @@ from .application import * -from .application_user import * +from .account import * from .mixin import * from .remote_app import * diff --git a/apps/applications/api/account.py b/apps/applications/api/account.py new file mode 100644 index 000000000..1874b2bab --- /dev/null +++ b/apps/applications/api/account.py @@ -0,0 +1,70 @@ +# coding: utf-8 +# + +from django_filters import rest_framework as filters +from django.conf import settings +from django.db.models import F, Value, CharField +from django.db.models.functions import Concat +from django.http import Http404 + +from common.drf.filters import BaseFilterSet +from common.drf.api import JMSModelViewSet +from common.utils import unique +from perms.models import ApplicationPermission +from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify +from .. import serializers + + +class AccountFilterSet(BaseFilterSet): + username = filters.CharFilter(field_name='username') + app = filters.CharFilter(field_name='applications', lookup_expr='exact') + app_name = filters.CharFilter(field_name='app_name', lookup_expr='exact') + + class Meta: + model = ApplicationPermission + fields = ['type', 'category'] + + +class ApplicationAccountViewSet(JMSModelViewSet): + permission_classes = (IsOrgAdmin, ) + search_fields = ['username', 'app_name'] + filterset_class = AccountFilterSet + filterset_fields = ['username', 'app_name', 'type', 'category'] + serializer_class = serializers.ApplicationAccountSerializer + + http_method_names = ['get', 'put', 'patch', 'options'] + + def get_queryset(self): + queryset = ApplicationPermission.objects.all() \ + .annotate(uid=Concat( + 'applications', Value('_'), 'system_users', output_field=CharField() + )) \ + .annotate(systemuser=F('system_users')) \ + .annotate(systemuser_display=F('system_users__name')) \ + .annotate(username=F('system_users__username')) \ + .annotate(password=F('system_users__password')) \ + .annotate(app=F('applications')) \ + .annotate(app_name=F("applications__name")) \ + .values('username', 'password', 'systemuser', 'systemuser_display', + 'app', 'app_name', 'category', 'type', 'uid') + return queryset + + def get_object(self): + obj = self.get_queryset().filter( + uid=self.kwargs['pk'] + ).first() + if not obj: + raise Http404() + return obj + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + queryset_list = unique(queryset, key=lambda x: (x['app'], x['systemuser'])) + return queryset_list + + +class ApplicationAccountSecretViewSet(ApplicationAccountViewSet): + serializer_class = serializers.ApplicationAccountSecretSerializer + permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify] + http_method_names = ['get', 'options'] + diff --git a/apps/applications/api/application.py b/apps/applications/api/application.py index 867a8ddd3..235f0e789 100644 --- a/apps/applications/api/application.py +++ b/apps/applications/api/application.py @@ -2,7 +2,10 @@ # from orgs.mixins.api import OrgBulkModelViewSet +from rest_framework.decorators import action +from rest_framework.response import Response +from common.tree import TreeNodeSerializer from ..hands import IsOrgAdminOrAppUser from .. import serializers from ..models import Application @@ -13,7 +16,22 @@ __all__ = ['ApplicationViewSet'] class ApplicationViewSet(OrgBulkModelViewSet): model = Application - filterset_fields = ('name', 'type', 'category') - search_fields = filterset_fields + filterset_fields = { + 'name': ['exact'], + 'category': ['exact'], + 'type': ['exact', 'in'], + } + search_fields = ('name', 'type', 'category') permission_classes = (IsOrgAdminOrAppUser,) - serializer_class = serializers.ApplicationSerializer + serializer_classes = { + 'default': serializers.ApplicationSerializer, + 'get_tree': TreeNodeSerializer + } + + @action(methods=['GET'], detail=False, url_path='tree') + def get_tree(self, request, *args, **kwargs): + show_count = request.query_params.get('show_count', '1') == '1' + queryset = self.filter_queryset(self.get_queryset()) + tree_nodes = Application.create_tree_nodes(queryset, show_count=show_count) + serializer = self.get_serializer(tree_nodes, many=True) + return Response(serializer.data) diff --git a/apps/applications/api/application_user.py b/apps/applications/api/application_user.py deleted file mode 100644 index 93bbc29a5..000000000 --- a/apps/applications/api/application_user.py +++ /dev/null @@ -1,55 +0,0 @@ -# coding: utf-8 -# - -from rest_framework import generics -from django.conf import settings - -from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify -from .. import serializers -from ..models import Application, ApplicationUser -from perms.models import ApplicationPermission - - -class ApplicationUserListApi(generics.ListAPIView): - permission_classes = (IsOrgAdmin, ) - filterset_fields = ('name', 'username') - search_fields = filterset_fields - serializer_class = serializers.ApplicationUserSerializer - _application = None - - @property - def application(self): - if self._application is None: - app_id = self.request.query_params.get('application_id') - if app_id: - self._application = Application.objects.get(id=app_id) - return self._application - - def get_serializer_context(self): - context = super().get_serializer_context() - context.update({ - 'application': self.application - }) - return context - - def get_queryset(self): - queryset = ApplicationUser.objects.none() - if not self.application: - return queryset - system_user_ids = ApplicationPermission.objects.filter(applications=self.application)\ - .values_list('system_users', flat=True) - if not system_user_ids: - return queryset - queryset = ApplicationUser.objects.filter(id__in=system_user_ids) - return queryset - - -class ApplicationUserAuthInfoListApi(ApplicationUserListApi): - serializer_class = serializers.ApplicationUserWithAuthInfoSerializer - http_method_names = ['get'] - permission_classes = [IsOrgAdminOrAppUser] - - def get_permissions(self): - if settings.SECURITY_VIEW_AUTH_NEED_MFA: - self.permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify] - return super().get_permissions() diff --git a/apps/applications/api/mixin.py b/apps/applications/api/mixin.py index f79902b72..ff939bf0b 100644 --- a/apps/applications/api/mixin.py +++ b/apps/applications/api/mixin.py @@ -1,89 +1,53 @@ -from orgs.models import Organization +from django.utils.translation import ugettext as _ +from common.tree import TreeNode +from orgs.models import Organization +from ..models import Application __all__ = ['SerializeApplicationToTreeNodeMixin'] class SerializeApplicationToTreeNodeMixin: - - @staticmethod - def _serialize_db(db): - return { - 'id': db.id, - 'name': db.name, - 'title': db.name, - 'pId': '', - 'open': False, - 'iconSkin': 'database', - 'meta': {'type': 'database_app'} - } - - @staticmethod - def _serialize_remote_app(remote_app): - return { - 'id': remote_app.id, - 'name': remote_app.name, - 'title': remote_app.name, - 'pId': '', - 'open': False, - 'isParent': False, - 'iconSkin': 'chrome', - 'meta': {'type': 'remote_app'} - } - - @staticmethod - def _serialize_cloud(cloud): - return { - 'id': cloud.id, - 'name': cloud.name, - 'title': cloud.name, - 'pId': '', - 'open': False, - 'isParent': False, - 'iconSkin': 'k8s', - 'meta': {'type': 'k8s_app'} - } - - 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(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): organization_ids = set(applications.values_list('org_id', flat=True)) organizations = [Organization.get_instance(org_id) for org_id in organization_ids] + organizations.sort(key=lambda x: x.name) return organizations + @staticmethod + def create_root_node(): + name = _('My applications') + node = TreeNode(**{ + 'id': 'applications', + 'name': name, + 'title': name, + 'pId': '', + 'open': True, + 'isParent': True, + 'meta': { + 'type': 'root' + } + }) + return node + def serialize_applications_with_org(self, applications): + root_node = self.create_root_node() + tree_nodes = [root_node] organizations = self.filter_organizations(applications) - data_organizations = self.serialize_organizations(organizations) - data_applications = self.serialize_applications(applications) - data = data_organizations + data_applications - return data + + for i, org in enumerate(organizations): + # 组织节点 + org_node = org.as_tree_node(pid=root_node.id) + tree_nodes.append(org_node) + org_applications = applications.filter(org_id=org.id) + count = org_applications.count() + org_node.name += '({})'.format(count) + + # 各应用节点 + apps_nodes = Application.create_tree_nodes( + queryset=org_applications, root_node=org_node, + show_empty=False + ) + tree_nodes += apps_nodes + return tree_nodes diff --git a/apps/applications/const.py b/apps/applications/const.py index 151a65b28..ee150da31 100644 --- a/apps/applications/const.py +++ b/apps/applications/const.py @@ -1,11 +1,10 @@ # coding: utf-8 # - from django.db.models import TextChoices from django.utils.translation import ugettext_lazy as _ -class ApplicationCategoryChoices(TextChoices): +class AppCategory(TextChoices): db = 'db', _('Database') remote_app = 'remote_app', _('Remote app') cloud = 'cloud', 'Cloud' @@ -15,7 +14,7 @@ class ApplicationCategoryChoices(TextChoices): return dict(cls.choices).get(category, '') -class ApplicationTypeChoices(TextChoices): +class AppType(TextChoices): # db category mysql = 'mysql', 'MySQL' oracle = 'oracle', 'Oracle' @@ -31,19 +30,38 @@ class ApplicationTypeChoices(TextChoices): # cloud category k8s = 'k8s', 'Kubernetes' + @classmethod + def category_types_mapper(cls): + return { + AppCategory.db: [cls.mysql, cls.oracle, cls.pgsql, cls.mariadb], + AppCategory.remote_app: [cls.chrome, cls.mysql_workbench, cls.vmware_client, cls.custom], + AppCategory.cloud: [cls.k8s] + } + + @classmethod + def type_category_mapper(cls): + mapper = {} + for category, tps in cls.category_types_mapper().items(): + for tp in tps: + mapper[tp] = category + return mapper + @classmethod def get_label(cls, tp): return dict(cls.choices).get(tp, '') @classmethod def db_types(cls): - return [cls.mysql.value, cls.oracle.value, cls.pgsql.value, cls.mariadb.value] + return [tp.value for tp in cls.category_types_mapper()[AppCategory.db]] @classmethod def remote_app_types(cls): - return [cls.chrome.value, cls.mysql_workbench.value, cls.vmware_client.value, cls.custom.value] + return [tp.value for tp in cls.category_types_mapper()[AppCategory.remote_app]] @classmethod def cloud_types(cls): - return [cls.k8s.value] + return [tp.value for tp in cls.category_types_mapper()[AppCategory.cloud]] + + + diff --git a/apps/applications/models/account.py b/apps/applications/models/account.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/applications/models/application.py b/apps/applications/models/application.py index c9374181d..7b641b7c4 100644 --- a/apps/applications/models/application.py +++ b/apps/applications/models/application.py @@ -1,19 +1,174 @@ +from collections import defaultdict + from django.db import models from django.utils.translation import ugettext_lazy as _ from orgs.mixins.models import OrgModelMixin from common.mixins import CommonModelMixin +from common.tree import TreeNode from assets.models import Asset, SystemUser from .. import const -class Application(CommonModelMixin, OrgModelMixin): +class ApplicationTreeNodeMixin: + id: str + name: str + type: str + category: str + + @classmethod + def create_choice_node(cls, c, id_, pid, tp, opened=False, counts=None, + show_empty=True, show_count=True): + count = counts.get(c.value, 0) + if count == 0 and not show_empty: + return None + label = c.label + if count is not None and show_count: + label = '{} ({})'.format(label, count) + data = { + 'id': id_, + 'name': label, + 'title': label, + 'pId': pid, + 'isParent': bool(count), + 'open': opened, + 'iconSkin': '', + 'meta': { + 'type': tp, + 'data': { + 'name': c.name, + 'value': c.value + } + } + } + return TreeNode(**data) + + @classmethod + def create_root_tree_node(cls, queryset, show_count=True): + count = queryset.count() if show_count else None + root_id = 'applications' + root_name = _('Applications') + if count is not None and show_count: + root_name = '{} ({})'.format(root_name, count) + node = TreeNode(**{ + 'id': root_id, + 'name': root_name, + 'title': root_name, + 'pId': '', + 'isParent': True, + 'open': True, + 'iconSkin': '', + 'meta': { + 'type': 'applications_root', + } + }) + return node + + @classmethod + def create_category_tree_nodes(cls, root_node, counts=None, show_empty=True, show_count=True): + nodes = [] + categories = const.AppType.category_types_mapper().keys() + for category in categories: + i = root_node.id + '_' + category.value + node = cls.create_choice_node( + category, i, pid=root_node.id, tp='category', + counts=counts, opened=False, show_empty=show_empty, + show_count=show_count + ) + if not node: + continue + nodes.append(node) + return nodes + + @classmethod + def create_types_tree_nodes(cls, root_node, counts, show_empty=True, show_count=True): + nodes = [] + type_category_mapper = const.AppType.type_category_mapper() + for tp in const.AppType.type_category_mapper().keys(): + category = type_category_mapper.get(tp) + pid = root_node.id + '_' + category.value + i = root_node.id + '_' + tp.value + node = cls.create_choice_node( + tp, i, pid, tp='type', counts=counts, opened=False, + show_empty=show_empty, show_count=show_count + ) + if not node: + continue + nodes.append(node) + return nodes + + @staticmethod + def get_tree_node_counts(queryset): + counts = defaultdict(int) + values = queryset.values_list('type', 'category') + for i in values: + tp = i[0] + category = i[1] + counts[tp] += 1 + counts[category] += 1 + return counts + + @classmethod + def create_tree_nodes(cls, queryset, root_node=None, show_empty=True, show_count=True): + counts = cls.get_tree_node_counts(queryset) + tree_nodes = [] + + # 根节点有可能是组织名称 + if root_node is None: + root_node = cls.create_root_tree_node(queryset, show_count=show_count) + tree_nodes.append(root_node) + + # 类别的节点 + tree_nodes += cls.create_category_tree_nodes( + root_node, counts, show_empty=show_empty, + show_count=show_count + ) + + # 类型的节点 + tree_nodes += cls.create_types_tree_nodes( + root_node, counts, show_empty=show_empty, + show_count=show_count + ) + + # 应用的节点 + for app in queryset: + pid = root_node.id + '_' + app.type + tree_nodes.append(app.as_tree_node(pid)) + return tree_nodes + + def as_tree_node(self, pid): + icon_skin_category_mapper = { + 'remote_app': 'chrome', + 'db': 'database', + 'cloud': 'cloud' + } + icon_skin = icon_skin_category_mapper.get(self.category, 'file') + node = TreeNode(**{ + 'id': str(self.id), + 'name': self.name, + 'title': self.name, + 'pId': pid, + 'isParent': False, + 'open': False, + 'iconSkin': icon_skin, + 'meta': { + 'type': 'application', + 'data': { + 'category': self.category, + 'type': self.type, + } + } + }) + return node + + +class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin): name = models.CharField(max_length=128, verbose_name=_('Name')) category = models.CharField( - max_length=16, choices=const.ApplicationCategoryChoices.choices, verbose_name=_('Category') + max_length=16, choices=const.AppCategory.choices, verbose_name=_('Category') ) type = models.CharField( - max_length=16, choices=const.ApplicationTypeChoices.choices, verbose_name=_('Type') + max_length=16, choices=const.AppType.choices, verbose_name=_('Type') ) domain = models.ForeignKey( 'assets.Domain', null=True, blank=True, related_name='applications', @@ -35,7 +190,7 @@ class Application(CommonModelMixin, OrgModelMixin): @property def category_remote_app(self): - return self.category == const.ApplicationCategoryChoices.remote_app.value + return self.category == const.AppCategory.remote_app.value def get_rdp_remote_app_setting(self): from applications.serializers.attrs import get_serializer_class_by_application_type diff --git a/apps/applications/serializers/application.py b/apps/applications/serializers/application.py index 39f7172a0..3aa2c642c 100644 --- a/apps/applications/serializers/application.py +++ b/apps/applications/serializers/application.py @@ -6,12 +6,12 @@ from django.utils.translation import ugettext_lazy as _ from orgs.mixins.serializers import BulkOrgResourceModelSerializer from common.drf.serializers import MethodSerializer from .attrs import category_serializer_classes_mapping, type_serializer_classes_mapping -from assets.serializers import SystemUserSerializer from .. import models +from .. import const __all__ = [ 'ApplicationSerializer', 'ApplicationSerializerMixin', - 'ApplicationUserSerializer', 'ApplicationUserWithAuthInfoSerializer' + 'ApplicationAccountSerializer', 'ApplicationAccountSecretSerializer' ] @@ -45,16 +45,15 @@ class ApplicationSerializerMixin(serializers.Serializer): class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSerializer): - category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category(Display)')) - type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type(Dispaly)')) + category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display')) + type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) class Meta: model = models.Application fields_mini = ['id', 'name'] fields_small = fields_mini + [ - 'category', 'category_display', 'type', 'type_display', 'attrs', - 'date_created', 'date_updated', - 'created_by', 'comment' + 'category', 'category_display', 'type', 'type_display', + 'attrs', 'date_created', 'date_updated', 'created_by', 'comment' ] fields_fk = ['domain'] fields = fields_small + fields_fk @@ -68,41 +67,34 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri return _attrs -class ApplicationUserSerializer(SystemUserSerializer): - application_name = serializers.SerializerMethodField(label=_('Application name')) - application_category = serializers.SerializerMethodField(label=_('Application category')) - application_type = serializers.SerializerMethodField(label=_('Application type')) +class ApplicationAccountSerializer(serializers.Serializer): + username = serializers.ReadOnlyField(label=_("Username")) + password = serializers.CharField(write_only=True, label=_("Password")) + systemuser = serializers.ReadOnlyField(label=_('System user')) + systemuser_display = serializers.ReadOnlyField(label=_("System user display")) + app = serializers.ReadOnlyField(label=_('App')) + uid = serializers.ReadOnlyField(label=_("Union id")) + app_name = serializers.ReadOnlyField(label=_("Application name"), read_only=True) + category = serializers.ChoiceField(label=_('Category'), choices=const.AppCategory.choices, read_only=True) + category_display = serializers.SerializerMethodField(label=_('Category display')) + type = serializers.ChoiceField(label=_('Type'), choices=const.AppType.choices, read_only=True) + type_display = serializers.SerializerMethodField(label=_('Type display')) - class Meta(SystemUserSerializer.Meta): - model = models.ApplicationUser - fields_mini = [ - 'id', 'application_name', 'application_category', 'application_type', 'name', 'username' - ] - fields_small = fields_mini + [ - 'protocol', 'login_mode', 'login_mode_display', 'priority', - "username_same_with_user", 'comment', - ] - fields = fields_small - extra_kwargs = { - 'login_mode_display': {'label': _('Login mode display')}, - 'created_by': {'read_only': True}, - } + category_mapper = dict(const.AppCategory.choices) + type_mapper = dict(const.AppType.choices) - @property - def application(self): - return self.context['application'] + def create(self, validated_data): + pass - def get_application_name(self, obj): - return self.application.name + def update(self, instance, validated_data): + pass - def get_application_category(self, obj): - return self.application.get_category_display() + def get_category_display(self, obj): + return self.category_mapper.get(obj['category']) - def get_application_type(self, obj): - return self.application.get_type_display() + def get_type_display(self, obj): + return self.type_mapper.get(obj['type']) -class ApplicationUserWithAuthInfoSerializer(ApplicationUserSerializer): - - class Meta(ApplicationUserSerializer.Meta): - fields = ApplicationUserSerializer.Meta.fields + ['password', 'token'] +class ApplicationAccountSecretSerializer(ApplicationAccountSerializer): + password = serializers.CharField(write_only=False, label=_("Password")) diff --git a/apps/applications/serializers/attrs/application_category/remote_app.py b/apps/applications/serializers/attrs/application_category/remote_app.py index 3cda4ac1e..fd5fa54f3 100644 --- a/apps/applications/serializers/attrs/application_category/remote_app.py +++ b/apps/applications/serializers/attrs/application_category/remote_app.py @@ -5,7 +5,7 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ObjectDoesNotExist -from common.utils import get_logger, is_uuid +from common.utils import get_logger, is_uuid, get_object_or_none from assets.models import Asset logger = get_logger(__file__) @@ -14,22 +14,26 @@ logger = get_logger(__file__) __all__ = ['RemoteAppSerializer'] -class CharPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): +class AssetCharPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): def to_internal_value(self, data): instance = super().to_internal_value(data) return str(instance.id) - def to_representation(self, value): - # value is instance.id + def to_representation(self, _id): + # _id 是 instance.id if self.pk_field is not None: - return self.pk_field.to_representation(value) - return value + return self.pk_field.to_representation(_id) + # 解决删除资产后,远程应用更新页面会显示资产ID的问题 + asset = get_object_or_none(Asset, id=_id) + if asset: + return None + return _id class RemoteAppSerializer(serializers.Serializer): asset_info = serializers.SerializerMethodField() - asset = CharPrimaryKeyRelatedField( + asset = AssetCharPrimaryKeyRelatedField( queryset=Asset.objects, required=False, label=_("Asset"), allow_null=True ) path = serializers.CharField( diff --git a/apps/applications/serializers/attrs/attrs.py b/apps/applications/serializers/attrs/attrs.py index 18d420fd1..09b7f48b1 100644 --- a/apps/applications/serializers/attrs/attrs.py +++ b/apps/applications/serializers/attrs/attrs.py @@ -14,9 +14,9 @@ __all__ = [ # --------------------------------------------------- category_serializer_classes_mapping = { - const.ApplicationCategoryChoices.db.value: application_category.DBSerializer, - const.ApplicationCategoryChoices.remote_app.value: application_category.RemoteAppSerializer, - const.ApplicationCategoryChoices.cloud.value: application_category.CloudSerializer, + const.AppCategory.db.value: application_category.DBSerializer, + const.AppCategory.remote_app.value: application_category.RemoteAppSerializer, + const.AppCategory.cloud.value: application_category.CloudSerializer, } # define `attrs` field `type serializers mapping` @@ -24,17 +24,17 @@ category_serializer_classes_mapping = { type_serializer_classes_mapping = { # db - const.ApplicationTypeChoices.mysql.value: application_type.MySQLSerializer, - const.ApplicationTypeChoices.mariadb.value: application_type.MariaDBSerializer, - const.ApplicationTypeChoices.oracle.value: application_type.OracleSerializer, - const.ApplicationTypeChoices.pgsql.value: application_type.PostgreSerializer, + const.AppType.mysql.value: application_type.MySQLSerializer, + const.AppType.mariadb.value: application_type.MariaDBSerializer, + const.AppType.oracle.value: application_type.OracleSerializer, + const.AppType.pgsql.value: application_type.PostgreSerializer, # remote-app - const.ApplicationTypeChoices.chrome.value: application_type.ChromeSerializer, - const.ApplicationTypeChoices.mysql_workbench.value: application_type.MySQLWorkbenchSerializer, - const.ApplicationTypeChoices.vmware_client.value: application_type.VMwareClientSerializer, - const.ApplicationTypeChoices.custom.value: application_type.CustomSerializer, + const.AppType.chrome.value: application_type.ChromeSerializer, + const.AppType.mysql_workbench.value: application_type.MySQLWorkbenchSerializer, + const.AppType.vmware_client.value: application_type.VMwareClientSerializer, + const.AppType.custom.value: application_type.CustomSerializer, # cloud - const.ApplicationTypeChoices.k8s.value: application_type.K8SSerializer + const.AppType.k8s.value: application_type.K8SSerializer } diff --git a/apps/applications/urls/api_urls.py b/apps/applications/urls/api_urls.py index fb5d08228..0fc0ec0fe 100644 --- a/apps/applications/urls/api_urls.py +++ b/apps/applications/urls/api_urls.py @@ -10,12 +10,14 @@ app_name = 'applications' router = BulkRouter() router.register(r'applications', api.ApplicationViewSet, 'application') +router.register(r'accounts', api.ApplicationAccountViewSet, 'application-account') +router.register(r'account-secrets', api.ApplicationAccountSecretViewSet, 'application-account-secret') urlpatterns = [ path('remote-apps//connection-info/', api.RemoteAppConnectionInfoApi.as_view(), name='remote-app-connection-info'), - path('application-users/', api.ApplicationUserListApi.as_view(), name='application-user'), - path('application-user-auth-infos/', api.ApplicationUserAuthInfoListApi.as_view(), name='application-user-auth-info') + # path('accounts/', api.ApplicationAccountViewSet.as_view(), name='application-account'), + # path('account-secrets/', api.ApplicationAccountSecretViewSet.as_view(), name='application-account-secret') ] diff --git a/apps/assets/api/accounts.py b/apps/assets/api/accounts.py index 525223ed3..ecd397dd6 100644 --- a/apps/assets/api/accounts.py +++ b/apps/assets/api/accounts.py @@ -1,15 +1,15 @@ from django.db.models import F, Q -from django.conf import settings from rest_framework.decorators import action from django_filters import rest_framework as filters from rest_framework.response import Response +from django.shortcuts import get_object_or_404 from rest_framework.generics import CreateAPIView from orgs.mixins.api import OrgBulkModelViewSet from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, NeedMFAVerify from common.drf.filters import BaseFilterSet from ..tasks.account_connectivity import test_accounts_connectivity_manual -from ..models import AuthBook +from ..models import AuthBook, Node from .. import serializers __all__ = ['AccountViewSet', 'AccountSecretsViewSet', 'AccountTaskCreateAPI'] @@ -19,11 +19,13 @@ class AccountFilterSet(BaseFilterSet): username = filters.CharFilter(method='do_nothing') ip = filters.CharFilter(field_name='ip', lookup_expr='exact') hostname = filters.CharFilter(field_name='hostname', lookup_expr='exact') + node = filters.CharFilter(method='do_nothing') @property def qs(self): qs = super().qs qs = self.filter_username(qs) + qs = self.filter_node(qs) return qs def filter_username(self, qs): @@ -33,6 +35,16 @@ class AccountFilterSet(BaseFilterSet): qs = qs.filter(Q(username=username) | Q(systemuser__username=username)).distinct() return qs + def filter_node(self, qs): + node_id = self.get_query_param('node') + if not node_id: + return qs + node = get_object_or_404(Node, pk=node_id) + node_ids = node.get_children(with_self=True).values_list('id', flat=True) + node_ids = list(node_ids) + qs = qs.filter(asset__nodes__in=node_ids) + return qs + class Meta: model = AuthBook fields = [ @@ -74,11 +86,6 @@ class AccountSecretsViewSet(AccountViewSet): permission_classes = (IsOrgAdmin, NeedMFAVerify) http_method_names = ['get'] - def get_permissions(self): - if not settings.SECURITY_VIEW_AUTH_NEED_MFA: - self.permission_classes = [IsOrgAdminOrAppUser] - return super().get_permissions() - class AccountTaskCreateAPI(CreateAPIView): permission_classes = (IsOrgAdminOrAppUser,) diff --git a/apps/assets/api/asset.py b/apps/assets/api/asset.py index b4ed17556..788b91257 100644 --- a/apps/assets/api/asset.py +++ b/apps/assets/api/asset.py @@ -9,10 +9,11 @@ from common.utils import get_logger, get_object_or_none from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser from orgs.mixins.api import OrgBulkModelViewSet from orgs.mixins import generics -from ..models import Asset, Node, Platform +from ..models import Asset, Node, Platform, SystemUser from .. import serializers from ..tasks import ( - update_assets_hardware_info_manual, test_assets_connectivity_manual + update_assets_hardware_info_manual, test_assets_connectivity_manual, + test_system_users_connectivity_a_asset, push_system_users_a_asset ) from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend @@ -94,21 +95,27 @@ class AssetPlatformViewSet(ModelViewSet): class AssetsTaskMixin: + def perform_assets_task(self, serializer): data = serializer.validated_data - assets = data['assets'] action = data['action'] + assets = data.get('assets', []) if action == "refresh": task = update_assets_hardware_info_manual.delay(assets) else: + # action == 'test': task = test_assets_connectivity_manual.delay(assets) + return task + + def perform_create(self, serializer): + task = self.perform_assets_task(serializer) + self.set_task_to_serializer_data(serializer, task) + + def set_task_to_serializer_data(self, serializer, task): data = getattr(serializer, '_data', {}) data["task"] = task.id setattr(serializer, '_data', data) - def perform_create(self, serializer): - self.perform_assets_task(serializer) - class AssetTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView): model = Asset @@ -117,13 +124,37 @@ class AssetTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView): def create(self, request, *args, **kwargs): pk = self.kwargs.get('pk') + request.data['asset'] = pk request.data['assets'] = [pk] return super().create(request, *args, **kwargs) + def perform_asset_task(self, serializer): + data = serializer.validated_data + action = data['action'] + if action not in ['push_system_user', 'test_system_user']: + return + asset = data['asset'] + system_users = data.get('system_users') + if not system_users: + system_users = asset.get_all_systemusers() + if action == 'push_system_user': + task = push_system_users_a_asset.delay(system_users, asset=asset) + elif action == 'test_system_user': + task = test_system_users_connectivity_a_asset.delay(system_users, asset=asset) + else: + task = None + return task + + def perform_create(self, serializer): + task = self.perform_asset_task(serializer) + if not task: + task = self.perform_assets_task(serializer) + self.set_task_to_serializer_data(serializer, task) + class AssetsTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView): model = Asset - serializer_class = serializers.AssetTaskSerializer + serializer_class = serializers.AssetsTaskSerializer permission_classes = (IsOrgAdmin,) diff --git a/apps/assets/api/domain.py b/apps/assets/api/domain.py index d444aa2ff..dbf7fa277 100644 --- a/apps/assets/api/domain.py +++ b/apps/assets/api/domain.py @@ -33,7 +33,7 @@ class GatewayViewSet(OrgBulkModelViewSet): model = Gateway filterset_fields = ("domain__name", "name", "username", "ip", "domain") search_fields = ("domain__name", "name", "username", "ip") - permission_classes = (IsOrgAdmin,) + permission_classes = (IsOrgAdminOrAppUser,) serializer_class = serializers.GatewaySerializer diff --git a/apps/assets/models/asset.py b/apps/assets/models/asset.py index 523ef7d07..91acd3d34 100644 --- a/apps/assets/models/asset.py +++ b/apps/assets/models/asset.py @@ -333,7 +333,7 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): 'iconSkin': icon_skin, 'meta': { 'type': 'asset', - 'asset': { + 'data': { 'id': self.id, 'hostname': self.hostname, 'ip': self.ip, @@ -345,6 +345,13 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin): tree_node = TreeNode(**data) return tree_node + def get_all_systemusers(self): + from .user import SystemUser + system_user_ids = SystemUser.assets.through.objects.filter(asset=self)\ + .values_list('systemuser_id', flat=True) + system_users = SystemUser.objects.filter(id__in=system_user_ids) + return system_users + class Meta: unique_together = [('org_id', 'hostname')] verbose_name = _("Asset") diff --git a/apps/assets/models/base.py b/apps/assets/models/base.py index 05c263e1e..e13d228bd 100644 --- a/apps/assets/models/base.py +++ b/apps/assets/models/base.py @@ -67,7 +67,10 @@ class AuthMixin: if self.public_key: public_key = self.public_key elif self.private_key: - public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password) + try: + public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password) + except IOError as e: + return str(e) else: return '' diff --git a/apps/assets/models/domain.py b/apps/assets/models/domain.py index e39cca48e..83b145249 100644 --- a/apps/assets/models/domain.py +++ b/apps/assets/models/domain.py @@ -3,17 +3,19 @@ import socket import uuid import random -import re +from django.core.cache import cache import paramiko from django.db import models from django.db.models import TextChoices from django.utils.translation import ugettext_lazy as _ -from common.utils.strings import no_special_chars +from common.utils import get_logger from orgs.mixins.models import OrgModelMixin from .base import BaseUser +logger = get_logger(__file__) + __all__ = ['Domain', 'Gateway'] @@ -40,10 +42,19 @@ class Domain(OrgModelMixin): return self.gateway_set.filter(is_active=True) def random_gateway(self): - return random.choice(self.gateways) + gateways = [gw for gw in self.gateways if gw.is_connective] + if gateways: + return random.choice(gateways) + else: + logger.warn(f'Gateway all bad. domain={self}, gateway_num={len(self.gateways)}.') + return random.choice(self.gateways) class Gateway(BaseUser): + UNCONNECTIVE_KEY_TMPL = 'asset_unconnective_gateway_{}' + UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL = 'asset_unconnective_gateway_silence_period_{}' + UNCONNECTIVE_SILENCE_PERIOD_BEGIN_VALUE = 60 * 5 + class Protocol(TextChoices): ssh = 'ssh', 'SSH' @@ -61,11 +72,40 @@ class Gateway(BaseUser): unique_together = [('name', 'org_id')] verbose_name = _("Gateway") + def set_unconnective(self): + unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id) + unconnective_silence_period_key = self.UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL.format(self.id) + + unconnective_silence_period = cache.get(unconnective_silence_period_key, + self.UNCONNECTIVE_SILENCE_PERIOD_BEGIN_VALUE) + cache.set(unconnective_silence_period_key, unconnective_silence_period * 2) + cache.set(unconnective_key, unconnective_silence_period, unconnective_silence_period) + + def set_connective(self): + unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id) + unconnective_silence_period_key = self.UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL.format(self.id) + + cache.delete(unconnective_key) + cache.delete(unconnective_silence_period_key) + + def get_is_unconnective(self): + unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id) + return cache.get(unconnective_key, False) + + @property + def is_connective(self): + return not self.get_is_unconnective() + + @is_connective.setter + def is_connective(self, value): + if value: + self.set_connective() + else: + self.set_unconnective() + def test_connective(self, local_port=None): if local_port is None: local_port = self.port - if self.password and not no_special_chars(self.password): - return False, _("Password should not contains special characters") client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) @@ -82,7 +122,14 @@ class Gateway(BaseUser): paramiko.SSHException, paramiko.ssh_exception.NoValidConnectionsError, socket.gaierror) as e: - return False, str(e) + err = str(e) + if err.startswith('[Errno None] Unable to connect to port'): + err = _('Unable to connect to port {port} on {ip}') + err = err.format(port=self.port, ip=self.ip) + elif err == 'Authentication failed.': + err = _('Authentication failed') + self.is_connective = False + return False, err try: sock = proxy.get_transport().open_channel( @@ -96,7 +143,9 @@ class Gateway(BaseUser): timeout=5) except (paramiko.SSHException, paramiko.ssh_exception.SSHException, paramiko.AuthenticationException, TimeoutError) as e: + self.is_connective = False return False, str(e) finally: client.close() + self.is_connective = True return True, None diff --git a/apps/assets/models/node.py b/apps/assets/models/node.py index 2e3b890a5..77614276a 100644 --- a/apps/assets/models/node.py +++ b/apps/assets/models/node.py @@ -608,7 +608,7 @@ class Node(OrgModelMixin, SomeNodesMixin, FamilyMixin, NodeAssetsMixin): 'isParent': True, 'open': self.is_org_root(), 'meta': { - 'node': { + 'data': { "id": self.id, "name": self.name, "value": self.value, diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index 914db0db9..ee802e311 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -60,10 +60,10 @@ class ProtocolMixin: @classmethod def get_protocol_by_application_type(cls, app_type): - from applications.const import ApplicationTypeChoices + from applications.const import AppType if app_type in cls.APPLICATION_CATEGORY_PROTOCOLS: protocol = app_type - elif app_type in ApplicationTypeChoices.remote_app_types(): + elif app_type in AppType.remote_app_types(): protocol = cls.Protocol.rdp else: protocol = None diff --git a/apps/assets/serializers/account.py b/apps/assets/serializers/account.py index 8b18e7e33..df0fdb72d 100644 --- a/apps/assets/serializers/account.py +++ b/apps/assets/serializers/account.py @@ -5,6 +5,7 @@ from assets.models import AuthBook from orgs.mixins.serializers import BulkOrgResourceModelSerializer from .base import AuthSerializerMixin +from .utils import validate_password_contains_left_double_curly_bracket class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): @@ -21,10 +22,15 @@ class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): fields = fields_small + fields_fk extra_kwargs = { 'username': {'required': True}, - 'password': {'write_only': True}, + 'password': { + 'write_only': True, + "validators": [validate_password_contains_left_double_curly_bracket] + }, 'private_key': {'write_only': True}, 'public_key': {'write_only': True}, + 'systemuser_display': {'label': _('System user display')} } + ref_name = 'AssetAccountSerializer' @classmethod def setup_eager_loading(cls, queryset): diff --git a/apps/assets/serializers/asset.py b/apps/assets/serializers/asset.py index 021610551..e86a167c9 100644 --- a/apps/assets/serializers/asset.py +++ b/apps/assets/serializers/asset.py @@ -10,7 +10,7 @@ from ..models import Asset, Node, Platform, SystemUser __all__ = [ 'AssetSerializer', 'AssetSimpleSerializer', 'ProtocolsField', 'PlatformSerializer', - 'AssetTaskSerializer', + 'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField' ] @@ -183,7 +183,7 @@ class AssetSimpleSerializer(serializers.ModelSerializer): fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified'] -class AssetTaskSerializer(serializers.Serializer): +class AssetsTaskSerializer(serializers.Serializer): ACTION_CHOICES = ( ('refresh', 'refresh'), ('test', 'test'), @@ -193,3 +193,16 @@ class AssetTaskSerializer(serializers.Serializer): assets = serializers.PrimaryKeyRelatedField( queryset=Asset.objects, required=False, allow_empty=True, many=True ) + + +class AssetTaskSerializer(AssetsTaskSerializer): + ACTION_CHOICES = tuple(list(AssetsTaskSerializer.ACTION_CHOICES) + [ + ('push_system_user', 'push_system_user'), + ('test_system_user', 'test_system_user') + ]) + asset = serializers.PrimaryKeyRelatedField( + queryset=Asset.objects, required=False, allow_empty=True, many=False + ) + system_users = serializers.PrimaryKeyRelatedField( + queryset=SystemUser.objects, required=False, allow_empty=True, many=True + ) diff --git a/apps/assets/serializers/domain.py b/apps/assets/serializers/domain.py index 1626bd711..60c69fb2b 100644 --- a/apps/assets/serializers/domain.py +++ b/apps/assets/serializers/domain.py @@ -4,14 +4,13 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ from orgs.mixins.serializers import BulkOrgResourceModelSerializer -from common.validators import NoSpecialChars from ..models import Domain, Gateway from .base import AuthSerializerMixin class DomainSerializer(BulkOrgResourceModelSerializer): - asset_count = serializers.SerializerMethodField(label=_('Assets count')) - application_count = serializers.SerializerMethodField(label=_('Applications count')) + asset_count = serializers.SerializerMethodField(label=_('Assets amount')) + application_count = serializers.SerializerMethodField(label=_('Applications amount')) gateway_count = serializers.SerializerMethodField(label=_('Gateways count')) class Meta: @@ -43,6 +42,8 @@ class DomainSerializer(BulkOrgResourceModelSerializer): class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): + is_connective = serializers.BooleanField(required=False) + class Meta: model = Gateway fields_mini = ['id', 'name'] @@ -51,14 +52,14 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): ] fields_small = fields_mini + fields_write_only + [ 'username', 'ip', 'port', 'protocol', - 'is_active', + 'is_active', 'is_connective', 'date_created', 'date_updated', 'created_by', 'comment', ] fields_fk = ['domain'] fields = fields_small + fields_fk extra_kwargs = { - 'password': {'write_only': True, 'validators': [NoSpecialChars()]}, + 'password': {'write_only': True}, 'private_key': {"write_only": True}, 'public_key': {"write_only": True}, } @@ -67,7 +68,7 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): class GatewayWithAuthSerializer(GatewaySerializer): class Meta(GatewaySerializer.Meta): extra_kwargs = { - 'password': {'write_only': False, 'validators': [NoSpecialChars()]}, + 'password': {'write_only': False}, 'private_key': {"write_only": False}, 'public_key': {"write_only": False}, } diff --git a/apps/assets/serializers/system_user.py b/apps/assets/serializers/system_user.py index bfd9fa511..c5b9c2064 100644 --- a/apps/assets/serializers/system_user.py +++ b/apps/assets/serializers/system_user.py @@ -6,6 +6,7 @@ from common.mixins.serializers import BulkSerializerMixin from common.utils import ssh_pubkey_gen from orgs.mixins.serializers import BulkOrgResourceModelSerializer from ..models import SystemUser, Asset +from .utils import validate_password_contains_left_double_curly_bracket from .base import AuthSerializerMixin __all__ = [ @@ -40,7 +41,10 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): fields_m2m = ['cmd_filters', 'assets_amount'] fields = fields_small + fields_m2m extra_kwargs = { - 'password': {"write_only": True}, + 'password': { + "write_only": True, + "validators": [validate_password_contains_left_double_curly_bracket] + }, 'public_key': {"write_only": True}, 'private_key': {"write_only": True}, 'token': {"write_only": True}, diff --git a/apps/assets/serializers/utils.py b/apps/assets/serializers/utils.py new file mode 100644 index 000000000..9110a9978 --- /dev/null +++ b/apps/assets/serializers/utils.py @@ -0,0 +1,9 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +def validate_password_contains_left_double_curly_bracket(password): + # validate password contains left double curly bracket + # check password not contains `{{` + if '{{' in password: + raise serializers.ValidationError(_('Password can not contains `{{` ')) diff --git a/apps/assets/tasks/gather_asset_users.py b/apps/assets/tasks/gather_asset_users.py index 0187a29aa..6ff2993d7 100644 --- a/apps/assets/tasks/gather_asset_users.py +++ b/apps/assets/tasks/gather_asset_users.py @@ -60,9 +60,12 @@ def parse_windows_result_to_users(result): task_result.pop() for line in task_result: - user = space.split(line) - if user[0]: - users[user[0]] = {} + username_list = space.split(line) + # such as: ['Admini', 'appadm', 'DefaultAccount', ''] + for username in username_list: + if not username: + continue + users[username] = {} return users diff --git a/apps/assets/tasks/push_system_user.py b/apps/assets/tasks/push_system_user.py index f640e03a1..7534e5de2 100644 --- a/apps/assets/tasks/push_system_user.py +++ b/apps/assets/tasks/push_system_user.py @@ -17,6 +17,7 @@ logger = get_logger(__file__) __all__ = [ 'push_system_user_util', 'push_system_user_to_assets', 'push_system_user_to_assets_manual', 'push_system_user_a_asset_manual', + 'push_system_users_a_asset' ] @@ -280,14 +281,21 @@ def push_system_user_a_asset_manual(system_user, asset, username=None): """ 将系统用户推送到一个资产上 """ - if username is None: - username = system_user.username + # if username is None: + # username = system_user.username task_name = _("Push system users to asset: {}({}) => {}").format( system_user.name, username, asset ) return push_system_user_util(system_user, [asset], task_name=task_name, username=username) +@shared_task(queue="ansible") +@tmp_to_root_org() +def push_system_users_a_asset(system_users, asset): + for system_user in system_users: + push_system_user_a_asset_manual(system_user, asset) + + @shared_task(queue="ansible") @tmp_to_root_org() def push_system_user_to_assets(system_user_id, asset_ids, username=None): diff --git a/apps/assets/tasks/system_user_connectivity.py b/apps/assets/tasks/system_user_connectivity.py index 5b0cf5845..d7252fa72 100644 --- a/apps/assets/tasks/system_user_connectivity.py +++ b/apps/assets/tasks/system_user_connectivity.py @@ -18,6 +18,7 @@ logger = get_logger(__name__) __all__ = [ 'test_system_user_connectivity_util', 'test_system_user_connectivity_manual', 'test_system_user_connectivity_period', 'test_system_user_connectivity_a_asset', + 'test_system_users_connectivity_a_asset' ] @@ -131,6 +132,12 @@ def test_system_user_connectivity_a_asset(system_user, asset): test_system_user_connectivity_util(system_user, [asset], task_name) +@shared_task(queue="ansible") +def test_system_users_connectivity_a_asset(system_users, asset): + for system_user in system_users: + test_system_user_connectivity_a_asset(system_user, asset) + + @shared_task(queue="ansible") def test_system_user_connectivity_period(): if not const.PERIOD_TASK_ENABLED: diff --git a/apps/audits/models.py b/apps/audits/models.py index aab3bbde9..bd7988dbe 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -5,7 +5,7 @@ from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from django.utils import timezone -from orgs.mixins.models import OrgModelMixin +from orgs.mixins.models import OrgModelMixin, Organization from orgs.utils import current_org __all__ = [ @@ -63,6 +63,11 @@ class OperateLog(OrgModelMixin): def __str__(self): return "<{}> {} <{}>".format(self.user, self.action, self.resource) + def save(self, *args, **kwargs): + if current_org.is_root() and not self.org_id: + self.org_id = Organization.ROOT_ID + return super(OperateLog, self).save(*args, **kwargs) + class PasswordChangeLog(models.Model): id = models.UUIDField(default=uuid.uuid4, primary_key=True) diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index 5f94fb42a..79b936c83 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -from django.db.models.signals import post_save, post_delete +from django.db.models.signals import post_save, post_delete, m2m_changed from django.dispatch import receiver from django.conf import settings from django.db import transaction @@ -11,6 +11,8 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework.renderers import JSONRenderer from rest_framework.request import Request +from assets.models import Asset +from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR from jumpserver.utils import current_request from common.utils import get_request_ip, get_logger, get_syslogger from users.models import User @@ -20,6 +22,9 @@ from terminal.models import Session, Command from common.utils.encode import model_to_json from .utils import write_login_log from . import models +from .models import OperateLog +from orgs.utils import current_org +from perms.models import AssetPermission, ApplicationPermission logger = get_logger(__name__) sys_logger = get_syslogger(__name__) @@ -90,6 +95,119 @@ def create_operate_log(action, sender, resource): logger.error("Create operate log error: {}".format(e)) +M2M_NEED_RECORD = { + 'OrganizationMember': ( + _('User and Organization'), + _('{User} *JOINED* {Organization}'), + _('{User} *LEFT* {Organization}') + ), + User.groups.through._meta.object_name: ( + _('User and Group'), + _('{User} *JOINED* {UserGroup}'), + _('{User} *LEFT* {UserGroup}') + ), + Asset.nodes.through._meta.object_name: ( + _('Node and Asset'), + _('{Node} *ADD* {Asset}'), + _('{Node} *REMOVE* {Asset}') + ), + AssetPermission.users.through._meta.object_name: ( + _('User asset permissions'), + _('{AssetPermission} *ADD* {User}'), + _('{AssetPermission} *REMOVE* {User}'), + ), + AssetPermission.user_groups.through._meta.object_name: ( + _('User group asset permissions'), + _('{AssetPermission} *ADD* {UserGroup}'), + _('{AssetPermission} *REMOVE* {UserGroup}'), + ), + AssetPermission.assets.through._meta.object_name: ( + _('Asset permission'), + _('{AssetPermission} *ADD* {Asset}'), + _('{AssetPermission} *REMOVE* {Asset}'), + ), + AssetPermission.nodes.through._meta.object_name: ( + _('Node permission'), + _('{AssetPermission} *ADD* {Node}'), + _('{AssetPermission} *REMOVE* {Node}'), + ), + AssetPermission.system_users.through._meta.object_name: ( + _('Asset permission and SystemUser'), + _('{AssetPermission} *ADD* {SystemUser}'), + _('{AssetPermission} *REMOVE* {SystemUser}'), + ), + ApplicationPermission.users.through._meta.object_name: ( + _('User application permissions'), + _('{ApplicationPermission} *ADD* {User}'), + _('{ApplicationPermission} *REMOVE* {User}'), + ), + ApplicationPermission.user_groups.through._meta.object_name: ( + _('User group application permissions'), + _('{ApplicationPermission} *ADD* {UserGroup}'), + _('{ApplicationPermission} *REMOVE* {UserGroup}'), + ), + ApplicationPermission.applications.through._meta.object_name: ( + _('Application permission'), + _('{ApplicationPermission} *ADD* {Application}'), + _('{ApplicationPermission} *REMOVE* {Application}'), + ), + ApplicationPermission.system_users.through._meta.object_name: ( + _('Application permission and SystemUser'), + _('{ApplicationPermission} *ADD* {SystemUser}'), + _('{ApplicationPermission} *REMOVE* {SystemUser}'), + ), +} + + +M2M_ACTION = { + POST_ADD: 'add', + POST_REMOVE: 'remove', + POST_CLEAR: 'remove', +} + + +@receiver(m2m_changed) +def on_m2m_changed(sender, action, instance, reverse, model, pk_set, **kwargs): + if action not in M2M_ACTION: + return + + user = current_request.user if current_request else None + if not user or not user.is_authenticated: + return + + sender_name = sender._meta.object_name + if sender_name in M2M_NEED_RECORD: + action = M2M_ACTION[action] + org_id = current_org.id + remote_addr = get_request_ip(current_request) + user = str(user) + resource_type, resource_tmpl_add, resource_tmpl_remove = M2M_NEED_RECORD[sender_name] + if action == 'add': + resource_tmpl = resource_tmpl_add + elif action == 'remove': + resource_tmpl = resource_tmpl_remove + + to_create = [] + objs = model.objects.filter(pk__in=pk_set) + + instance_name = instance._meta.object_name + instance_value = str(instance) + + model_name = model._meta.object_name + + for obj in objs: + resource = resource_tmpl.format(**{ + instance_name: instance_value, + model_name: str(obj) + })[:128] # `resource` 字段只有 128 个字符长 😔 + + to_create.append(OperateLog( + user=user, action=action, resource_type=resource_type, + resource=resource, remote_addr=remote_addr, org_id=org_id + )) + OperateLog.objects.bulk_create(to_create) + + @receiver(post_save) def on_object_created_or_update(sender, instance=None, created=False, update_fields=None, **kwargs): # last_login 改变是最后登录日期, 每次登录都会改变 diff --git a/apps/authentication/api/__init__.py b/apps/authentication/api/__init__.py index 6ef54b09b..c0064f9bd 100644 --- a/apps/authentication/api/__init__.py +++ b/apps/authentication/api/__init__.py @@ -9,4 +9,5 @@ from .login_confirm import * from .sso import * from .wecom import * from .dingtalk import * +from .feishu import * from .password import * diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index a160eea92..383a87c32 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- # import urllib.parse +import json +import base64 +from typing import Callable from django.conf import settings from django.core.cache import cache @@ -8,6 +11,7 @@ from django.shortcuts import get_object_or_404 from django.http import HttpResponse from django.utils.translation import ugettext as _ from rest_framework.response import Response +from rest_framework.request import Request from rest_framework.viewsets import GenericViewSet from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -17,90 +21,40 @@ from authentication.signals import post_auth_failed, post_auth_success from common.utils import get_logger, random_string from common.drf.api import SerializerMixin from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser - from orgs.mixins.api import RootOrgViewMixin +from common.http import is_true from ..serializers import ( ConnectionTokenSerializer, ConnectionTokenSecretSerializer, - RDPFileSerializer ) logger = get_logger(__name__) __all__ = ['UserConnectionTokenViewSet'] -class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewSet): - permission_classes = (IsSuperUserOrAppUser,) - serializer_classes = { - 'default': ConnectionTokenSerializer, - 'get_secret_detail': ConnectionTokenSecretSerializer, - 'get_rdp_file': RDPFileSerializer - } - CACHE_KEY_PREFIX = 'CONNECTION_TOKEN_{}' - - @staticmethod - def check_resource_permission(user, asset, application, system_user): - from perms.utils.asset import has_asset_system_permission - from perms.utils.application import has_application_system_permission - if asset and not has_asset_system_permission(user, asset, system_user): - error = f'User not has this asset and system user permission: ' \ - f'user={user.id} system_user={system_user.id} asset={asset.id}' - raise PermissionDenied(error) - if application and not has_application_system_permission(user, application, system_user): - error = f'User not has this application and system user permission: ' \ - f'user={user.id} system_user={system_user.id} application={application.id}' - raise PermissionDenied(error) - return True - - def create_token(self, user, asset, application, system_user, ttl=5*60): - if not self.request.user.is_superuser and user != self.request.user: - raise PermissionDenied('Only super user can create user token') - self.check_resource_permission(user, asset, application, system_user) - token = random_string(36) - value = { - 'user': str(user.id), - 'username': user.username, - 'system_user': str(system_user.id), - 'system_user_name': system_user.name - } - - if asset: - value.update({ - 'type': 'asset', - 'asset': str(asset.id), - 'hostname': asset.hostname, - }) - elif application: - value.update({ - 'type': 'application', - 'application': application.id, - 'application_name': str(application) - }) - - key = self.CACHE_KEY_PREFIX.format(token) - cache.set(key, value, timeout=ttl) - return token - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) +class ClientProtocolMixin: + request: Request + get_serializer: Callable + create_token: Callable + def get_request_resource(self, serializer): asset = serializer.validated_data.get('asset') application = serializer.validated_data.get('application') system_user = serializer.validated_data['system_user'] - user = serializer.validated_data.get('user') - token = self.create_token(user, asset, application, system_user) - return Response({"token": token}, status=201) - @action(methods=['POST', 'GET'], detail=False, url_path='rdp/file', permission_classes=[IsValidUser]) - def get_rdp_file(self, request, *args, **kwargs): + user = serializer.validated_data.get('user') + if not user or not self.request.user.is_superuser: + user = self.request.user + return asset, application, system_user, user + + def get_rdp_file_content(self, serializer): options = { 'full address:s': '', 'username:s': '', - 'screen mode id:i': '0', + # 'screen mode id:i': '1', # 'desktopwidth:i': '1280', # 'desktopheight:i': '800', - 'use multimon:i': '1', + 'use multimon:i': '0', 'session bpp:i': '32', 'audiomode:i': '0', 'disable wallpaper:i': '0', @@ -125,24 +79,17 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS # 'remoteapplicationname:s': 'Firefox', # 'remoteapplicationcmdline:s': '', } - if self.request.method == 'GET': - data = self.request.query_params - else: - data = request.data - serializer = self.get_serializer(data=data) - serializer.is_valid(raise_exception=True) - asset = serializer.validated_data.get('asset') - application = serializer.validated_data.get('application') - system_user = serializer.validated_data['system_user'] - height = serializer.validated_data.get('height') - width = serializer.validated_data.get('width') - user = request.user + asset, application, system_user, user = self.get_request_resource(serializer) + height = self.request.query_params.get('height') + width = self.request.query_params.get('width') + full_screen = is_true(self.request.query_params.get('full_screen')) token = self.create_token(user, asset, application, system_user) + options['screen mode id:i'] = '2' if full_screen else '1' address = settings.TERMINAL_RDP_ADDR if not address or address == 'localhost:3389': - address = request.get_host().split(':')[0] + ':3389' + address = self.request.get_host().split(':')[0] + ':3389' options['full address:s'] = address options['username:s'] = '{}|{}'.format(user.username, token) if system_user.ad_domain: @@ -152,21 +99,73 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS options['desktopheight:i'] = height else: options['smart sizing:i'] = '1' - data = '' + content = '' for k, v in options.items(): - data += f'{k}:{v}\n' + content += f'{k}:{v}\n' if asset: name = asset.hostname elif application: name = application.name else: name = '*' + return name, content + + @action(methods=['POST', 'GET'], detail=False, url_path='rdp/file', permission_classes=[IsValidUser]) + def get_rdp_file(self, request, *args, **kwargs): + if self.request.method == 'GET': + data = self.request.query_params + else: + data = self.request.data + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + name, data = self.get_rdp_file_content(serializer) response = HttpResponse(data, content_type='application/octet-stream') - filename = "{}-{}-jumpserver.rdp".format(user.username, name) + filename = "{}-{}-jumpserver.rdp".format(self.request.user.username, name) filename = urllib.parse.quote(filename) response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename return response + def get_valid_serializer(self): + if self.request.method == 'GET': + data = self.request.query_params + else: + data = self.request.data + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + return serializer + + def get_client_protocol_data(self, serializer): + asset, application, system_user, user = self.get_request_resource(serializer) + protocol = system_user.protocol + if protocol == 'rdp': + name, config = self.get_rdp_file_content(serializer) + elif protocol == 'vnc': + raise HttpResponse(status=404, data={"error": "VNC not support"}) + else: + config = 'ssh://system_user@asset@user@jumpserver-ssh' + data = { + "protocol": system_user.protocol, + "username": user.username, + "config": config + } + return data + + @action(methods=['POST', 'GET'], detail=False, url_path='client-url', permission_classes=[IsValidUser]) + def get_client_protocol_url(self, request, *args, **kwargs): + serializer = self.get_valid_serializer() + protocol_data = self.get_client_protocol_data(serializer) + protocol_data = base64.b64encode(json.dumps(protocol_data).encode()).decode() + data = { + 'url': 'jms://{}'.format(protocol_data), + } + return Response(data=data) + + +class SecretDetailMixin: + valid_token: Callable + request: Request + get_serializer: Callable + @staticmethod def _get_application_secret_detail(application): from perms.models import Action @@ -212,6 +211,100 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS 'actions': actions, } + @action(methods=['POST'], detail=False, permission_classes=[IsSuperUserOrAppUser], url_path='secret-info/detail') + def get_secret_detail(self, request, *args, **kwargs): + token = request.data.get('token', '') + try: + value, user, system_user, asset, app, expired_at = self.valid_token(token) + except serializers.ValidationError as e: + post_auth_failed.send( + sender=self.__class__, username='', request=self.request, + reason=_('Invalid token') + ) + raise e + + data = dict(user=user, system_user=system_user, expired_at=expired_at) + if asset: + asset_detail = self._get_asset_secret_detail(asset, user=user, system_user=system_user) + system_user.load_asset_more_auth(asset.id, user.username, user.id) + data['type'] = 'asset' + data.update(asset_detail) + else: + app_detail = self._get_application_secret_detail(app) + system_user.load_app_more_auth(app.id, user.id) + data['type'] = 'application' + data.update(app_detail) + + self.request.session['auth_backend'] = settings.AUTH_BACKEND_AUTH_TOKEN + post_auth_success.send(sender=self.__class__, user=user, request=self.request, login_type='T') + + serializer = self.get_serializer(data) + return Response(data=serializer.data, status=200) + + +class UserConnectionTokenViewSet( + RootOrgViewMixin, SerializerMixin, ClientProtocolMixin, + SecretDetailMixin, GenericViewSet +): + permission_classes = (IsSuperUserOrAppUser,) + serializer_classes = { + 'default': ConnectionTokenSerializer, + 'get_secret_detail': ConnectionTokenSecretSerializer, + } + CACHE_KEY_PREFIX = 'CONNECTION_TOKEN_{}' + + @staticmethod + def check_resource_permission(user, asset, application, system_user): + from perms.utils.asset import has_asset_system_permission + from perms.utils.application import has_application_system_permission + + if asset and not has_asset_system_permission(user, asset, system_user): + error = f'User not has this asset and system user permission: ' \ + f'user={user.id} system_user={system_user.id} asset={asset.id}' + raise PermissionDenied(error) + if application and not has_application_system_permission(user, application, system_user): + error = f'User not has this application and system user permission: ' \ + f'user={user.id} system_user={system_user.id} application={application.id}' + raise PermissionDenied(error) + return True + + def create_token(self, user, asset, application, system_user, ttl=5 * 60): + if not self.request.user.is_superuser and user != self.request.user: + raise PermissionDenied('Only super user can create user token') + self.check_resource_permission(user, asset, application, system_user) + token = random_string(36) + value = { + 'user': str(user.id), + 'username': user.username, + 'system_user': str(system_user.id), + 'system_user_name': system_user.name + } + + if asset: + value.update({ + 'type': 'asset', + 'asset': str(asset.id), + 'hostname': asset.hostname, + }) + elif application: + value.update({ + 'type': 'application', + 'application': application.id, + 'application_name': str(application) + }) + + key = self.CACHE_KEY_PREFIX.format(token) + cache.set(key, value, timeout=ttl) + return token + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + asset, application, system_user, user = self.get_request_resource(serializer) + token = self.create_token(user, asset, application, system_user) + return Response({"token": token}, status=201) + def valid_token(self, token): from users.models import User from assets.models import SystemUser, Asset @@ -244,39 +337,8 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS if not has_perm: raise serializers.ValidationError('Permission expired or invalid') - return value, user, system_user, asset, app, expired_at - @action(methods=['POST'], detail=False, permission_classes=[IsSuperUserOrAppUser], url_path='secret-info/detail') - def get_secret_detail(self, request, *args, **kwargs): - token = request.data.get('token', '') - try: - value, user, system_user, asset, app, expired_at = self.valid_token(token) - except serializers.ValidationError as e: - post_auth_failed.send( - sender=self.__class__, username='', request=self.request, - reason=_('Invalid token') - ) - raise e - - data = dict(user=user, system_user=system_user, expired_at=expired_at) - if asset: - asset_detail = self._get_asset_secret_detail(asset, user=user, system_user=system_user) - system_user.load_asset_more_auth(asset.id, user.username, user.id) - data['type'] = 'asset' - data.update(asset_detail) - else: - app_detail = self._get_application_secret_detail(app) - system_user.load_app_more_auth(app.id, user.id) - data['type'] = 'application' - data.update(app_detail) - - self.request.session['auth_backend'] = settings.AUTH_BACKEND_AUTH_TOKEN - post_auth_success.send(sender=self.__class__, user=user, request=self.request, login_type='T') - - serializer = self.get_serializer(data) - return Response(data=serializer.data, status=200) - def get_permissions(self): if self.action in ["create", "get_rdp_file"]: if self.request.data.get('user', None): diff --git a/apps/authentication/api/feishu.py b/apps/authentication/api/feishu.py new file mode 100644 index 000000000..1665d057a --- /dev/null +++ b/apps/authentication/api/feishu.py @@ -0,0 +1,45 @@ +from rest_framework.views import APIView +from rest_framework.request import Request +from rest_framework.response import Response + +from users.permissions import IsAuthPasswdTimeValid +from users.models import User +from common.utils import get_logger +from common.permissions import IsOrgAdmin +from common.mixins.api import RoleUserMixin, RoleAdminMixin +from authentication import errors + +logger = get_logger(__file__) + + +class FeiShuQRUnBindBase(APIView): + user: User + + def post(self, request: Request, **kwargs): + user = self.user + + if not user.feishu_id: + raise errors.FeiShuNotBound + + user.feishu_id = None + user.save() + return Response() + + +class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase): + permission_classes = (IsAuthPasswdTimeValid,) + + +class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase): + user_id_url_kwarg = 'user_id' + permission_classes = (IsOrgAdmin,) + + +class FeiShuEventSubscriptionCallback(APIView): + """ + # https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM + """ + permission_classes = () + + def post(self, request: Request, *args, **kwargs): + return Response(data=request.data) diff --git a/apps/authentication/api/sso.py b/apps/authentication/api/sso.py index 04149e9a5..66febfbd7 100644 --- a/apps/authentication/api/sso.py +++ b/apps/authentication/api/sso.py @@ -11,7 +11,7 @@ from rest_framework.permissions import AllowAny from common.utils.timezone import utcnow from common.const.http import POST, GET -from common.drf.api import JmsGenericViewSet +from common.drf.api import JMSGenericViewSet from common.drf.serializers import EmptySerializer from common.permissions import IsSuperUser from common.utils import reverse @@ -26,7 +26,7 @@ NEXT_URL = 'next' AUTH_KEY = 'authkey' -class SSOViewSet(AuthMixin, JmsGenericViewSet): +class SSOViewSet(AuthMixin, JMSGenericViewSet): queryset = SSOToken.objects.all() serializer_classes = { 'login_url': SSOTokenSerializer, diff --git a/apps/authentication/backends/api.py b/apps/authentication/backends/api.py index 892ebcc7c..79d420626 100644 --- a/apps/authentication/backends/api.py +++ b/apps/authentication/backends/api.py @@ -240,6 +240,15 @@ class DingTalkAuthentication(JMSModelBackend): pass +class FeiShuAuthentication(JMSModelBackend): + """ + 什么也不做呀😺 + """ + + def authenticate(self, request, **kwargs): + pass + + class AuthorizationTokenAuthentication(JMSModelBackend): """ 什么也不做呀😺 diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index bcd83c97d..ad8148182 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -315,6 +315,11 @@ class DingTalkNotBound(JMSException): default_detail = 'DingTalk is not bound' +class FeiShuNotBound(JMSException): + default_code = 'feishu_not_bound' + default_detail = 'FeiShu is not bound' + + class PasswdInvalid(JMSException): default_code = 'passwd_invalid' default_detail = _('Your password is invalid') diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index e6932388b..989d94d62 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -198,7 +198,3 @@ class ConnectionTokenSecretSerializer(serializers.Serializer): actions = ActionsField() expired_at = serializers.IntegerField() - -class RDPFileSerializer(ConnectionTokenSerializer): - width = serializers.IntegerField(allow_null=True, max_value=3112, min_value=100, required=False) - height = serializers.IntegerField(allow_null=True, max_value=4096, min_value=100, required=False) diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index 0104d0e44..c54f792c7 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -191,7 +191,7 @@
- {% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK %} + {% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK or AUTH_FEISHU %}
{% trans "More login options" %} @@ -215,6 +215,11 @@ {% trans 'DingTalk' %} {% endif %} + {% if AUTH_FEISHU %} + + {% endif %}
{% else %} diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 0849cc82a..d8613adf4 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -20,6 +20,10 @@ urlpatterns = [ path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'), path('dingtalk/qr/unbind//', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'), + path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'), + path('feishu/qr/unbind//', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'), + path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'), + path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'), diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index 8e754340c..0bac07e25 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -37,6 +37,14 @@ urlpatterns = [ path('dingtalk/qr/bind//callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'), path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'), + path('feishu/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='feishu-bind-success-flash-msg'), + path('feishu/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='feishu-bind-failed-flash-msg'), + path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'), + path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'), + path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'), + path('feishu/qr/bind/callback/', views.FeiShuQRBindCallbackView.as_view(), name='feishu-qr-bind-callback'), + path('feishu/qr/login/callback/', views.FeiShuQRLoginCallbackView.as_view(), name='feishu-qr-login-callback'), + # Profile path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'), path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'), diff --git a/apps/authentication/views/__init__.py b/apps/authentication/views/__init__.py index 0467e321a..38cf114e1 100644 --- a/apps/authentication/views/__init__.py +++ b/apps/authentication/views/__init__.py @@ -4,3 +4,4 @@ from .login import * from .mfa import * from .wecom import * from .dingtalk import * +from .feishu import * diff --git a/apps/authentication/views/feishu.py b/apps/authentication/views/feishu.py new file mode 100644 index 000000000..2db1404d7 --- /dev/null +++ b/apps/authentication/views/feishu.py @@ -0,0 +1,253 @@ +import urllib + +from django.http.response import HttpResponseRedirect, HttpResponse +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.cache import never_cache +from django.views.generic import TemplateView +from django.views import View +from django.conf import settings +from django.http.request import HttpRequest +from django.db.utils import IntegrityError +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.exceptions import APIException + +from users.utils import is_auth_password_time_valid +from users.views import UserVerifyPasswordView +from users.models import User +from common.utils import get_logger +from common.utils.random import random_string +from common.utils.django import reverse, get_object_or_none +from common.mixins.views import PermissionsMixin +from common.message.backends.feishu import FeiShu, URL +from authentication import errors +from authentication.mixins import AuthMixin + +logger = get_logger(__file__) + + +FEISHU_STATE_SESSION_KEY = '_feishu_state' + + +class FeiShuQRMixin(PermissionsMixin, View): + def dispatch(self, request, *args, **kwargs): + try: + return super().dispatch(request, *args, **kwargs) + except APIException as e: + msg = str(e.detail) + return self.get_failed_reponse( + '/', + _('FeiShu Error'), + msg + ) + + def verify_state(self): + state = self.request.GET.get('state') + session_state = self.request.session.get(FEISHU_STATE_SESSION_KEY) + if state != session_state: + return False + return True + + def get_verify_state_failed_response(self, redirect_uri): + msg = _("You've been hacked") + return self.get_failed_reponse(redirect_uri, msg, msg) + + def get_qr_url(self, redirect_uri): + state = random_string(16) + self.request.session[FEISHU_STATE_SESSION_KEY] = state + + params = { + 'app_id': settings.FEISHU_APP_ID, + 'state': state, + 'redirect_uri': redirect_uri, + } + url = URL.AUTHEN + '?' + urllib.parse.urlencode(params) + return url + + def get_success_reponse(self, redirect_url, title, msg): + ok_flash_msg_url = reverse('authentication:feishu-bind-success-flash-msg') + ok_flash_msg_url += '?' + urllib.parse.urlencode({ + 'redirect_url': redirect_url, + 'title': title, + 'msg': msg + }) + return HttpResponseRedirect(ok_flash_msg_url) + + def get_failed_reponse(self, redirect_url, title, msg): + failed_flash_msg_url = reverse('authentication:feishu-bind-failed-flash-msg') + failed_flash_msg_url += '?' + urllib.parse.urlencode({ + 'redirect_url': redirect_url, + 'title': title, + 'msg': msg + }) + return HttpResponseRedirect(failed_flash_msg_url) + + def get_already_bound_response(self, redirect_url): + msg = _('FeiShu is already bound') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + +class FeiShuQRBindView(FeiShuQRMixin, View): + permission_classes = (IsAuthenticated,) + + def get(self, request: HttpRequest): + user = request.user + redirect_url = request.GET.get('redirect_url') + + if not is_auth_password_time_valid(request.session): + msg = _('Please verify your password first') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True) + redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url}) + + url = self.get_qr_url(redirect_uri) + return HttpResponseRedirect(url) + + +class FeiShuQRBindCallbackView(FeiShuQRMixin, View): + permission_classes = (IsAuthenticated,) + + def get(self, request: HttpRequest): + code = request.GET.get('code') + redirect_url = request.GET.get('redirect_url') + + if not self.verify_state(): + return self.get_verify_state_failed_response(redirect_url) + + user = request.user + + if user.feishu_id: + response = self.get_already_bound_response(redirect_url) + return response + + feishu = FeiShu( + app_id=settings.FEISHU_APP_ID, + app_secret=settings.FEISHU_APP_SECRET + ) + user_id = feishu.get_user_id_by_code(code) + + if not user_id: + msg = _('FeiShu query user failed') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + + try: + user.feishu_id = user_id + user.save() + except IntegrityError as e: + if e.args[0] == 1062: + msg = _('The FeiShu is already bound to another user') + response = self.get_failed_reponse(redirect_url, msg, msg) + return response + raise e + + msg = _('Binding FeiShu successfully') + response = self.get_success_reponse(redirect_url, msg, msg) + return response + + +class FeiShuEnableStartView(UserVerifyPasswordView): + + def get_success_url(self): + referer = self.request.META.get('HTTP_REFERER') + redirect_url = self.request.GET.get("redirect_url") + + success_url = reverse('authentication:feishu-qr-bind') + + success_url += '?' + urllib.parse.urlencode({ + 'redirect_url': redirect_url or referer + }) + + return success_url + + +class FeiShuQRLoginView(FeiShuQRMixin, View): + permission_classes = (AllowAny,) + + def get(self, request: HttpRequest): + redirect_url = request.GET.get('redirect_url') + + redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True) + redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url}) + + url = self.get_qr_url(redirect_uri) + return HttpResponseRedirect(url) + + +class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View): + permission_classes = (AllowAny,) + + def get(self, request: HttpRequest): + code = request.GET.get('code') + redirect_url = request.GET.get('redirect_url') + login_url = reverse('authentication:login') + + if not self.verify_state(): + return self.get_verify_state_failed_response(redirect_url) + + feishu = FeiShu( + app_id=settings.FEISHU_APP_ID, + app_secret=settings.FEISHU_APP_SECRET + ) + user_id = feishu.get_user_id_by_code(code) + if not user_id: + # 正常流程不会出这个错误,hack 行为 + msg = _('Failed to get user from FeiShu') + response = self.get_failed_reponse(login_url, title=msg, msg=msg) + return response + + user = get_object_or_none(User, feishu_id=user_id) + if user is None: + title = _('FeiShu is not bound') + msg = _('Please login with a password and then bind the WeCom') + response = self.get_failed_reponse(login_url, title=title, msg=msg) + return response + + try: + self.check_oauth2_auth(user, settings.AUTH_BACKEND_FEISHU) + except errors.AuthFailedError as e: + self.set_login_failed_mark() + msg = e.msg + response = self.get_failed_reponse(login_url, title=msg, msg=msg) + return response + + return self.redirect_to_guard_view() + + +@method_decorator(never_cache, name='dispatch') +class FlashFeiShuBindSucceedMsgView(TemplateView): + template_name = 'flash_message_standalone.html' + + def get(self, request, *args, **kwargs): + title = request.GET.get('title') + msg = request.GET.get('msg') + + context = { + 'title': title or _('Binding FeiShu successfully'), + 'messages': msg or _('Binding FeiShu successfully'), + 'interval': 5, + 'redirect_url': request.GET.get('redirect_url'), + 'auto_redirect': True, + } + return self.render_to_response(context) + + +@method_decorator(never_cache, name='dispatch') +class FlashFeiShuBindFailedMsgView(TemplateView): + template_name = 'flash_message_standalone.html' + + def get(self, request, *args, **kwargs): + title = request.GET.get('title') + msg = request.GET.get('msg') + + context = { + 'title': title or _('Binding FeiShu failed'), + 'messages': msg or _('Binding FeiShu failed'), + 'interval': 5, + 'redirect_url': request.GET.get('redirect_url'), + 'auto_redirect': True, + } + return self.render_to_response(context) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 0aa9f90b5..6a2481d20 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -46,24 +46,44 @@ class UserLoginView(mixins.AuthMixin, FormView): return None next_url = request.GET.get('next') or '/' auth_type = '' - auth_url = '' + if settings.AUTH_OPENID: auth_type = 'OIDC' - auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}' - elif settings.AUTH_CAS: + openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}' + else: + openid_auth_url = None + + if settings.AUTH_CAS: auth_type = 'CAS' - auth_url = reverse(settings.CAS_LOGIN_URL_NAME) + f'?next={next_url}' - if not auth_url: + cas_auth_url = reverse(settings.CAS_LOGIN_URL_NAME) + f'?next={next_url}' + else: + cas_auth_url = None + + if not any([openid_auth_url, cas_auth_url]): return None - message_data = { - 'title': _('Redirecting'), - 'message': _("Redirecting to {} authentication").format(auth_type), - 'redirect_url': auth_url, - 'has_cancel': True, - 'cancel_url': reverse('authentication:login') + '?admin=1' - } - redirect_url = FlashMessageUtil.gen_message_url(message_data) + if settings.LOGIN_REDIRECT_TO_BACKEND == 'OPENID' and openid_auth_url: + auth_url = openid_auth_url + + elif settings.LOGIN_REDIRECT_TO_BACKEND == 'CAS' and cas_auth_url: + auth_url = cas_auth_url + + else: + auth_url = openid_auth_url or cas_auth_url + + if settings.LOGIN_REDIRECT_TO_BACKEND: + redirect_url = auth_url + else: + message_data = { + 'title': _('Redirecting'), + 'message': _("Redirecting to {} authentication").format(auth_type), + 'redirect_url': auth_url, + 'interval': 3, + 'has_cancel': True, + 'cancel_url': reverse('authentication:login') + '?admin=1' + } + redirect_url = FlashMessageUtil.gen_message_url(message_data) + query_string = request.GET.urlencode() redirect_url = "{}&{}".format(redirect_url, query_string) return redirect_url @@ -134,6 +154,7 @@ class UserLoginView(mixins.AuthMixin, FormView): 'AUTH_CAS': settings.AUTH_CAS, 'AUTH_WECOM': settings.AUTH_WECOM, 'AUTH_DINGTALK': settings.AUTH_DINGTALK, + 'AUTH_FEISHU': settings.AUTH_FEISHU, 'rsa_public_key': rsa_public_key, 'forgot_password_url': forgot_password_url } diff --git a/apps/common/decorator.py b/apps/common/decorator.py index 6edc4f3c3..1fc2b2a88 100644 --- a/apps/common/decorator.py +++ b/apps/common/decorator.py @@ -10,3 +10,15 @@ def on_transaction_commit(func): def inner(*args, **kwargs): transaction.on_commit(lambda: func(*args, **kwargs)) return inner + + +class Singleton(object): + """ 单例类 """ + def __init__(self, cls): + self._cls = cls + self._instance = {} + + def __call__(self): + if self._cls not in self._instance: + self._instance[self._cls] = self._cls() + return self._instance[self._cls] diff --git a/apps/common/drf/api.py b/apps/common/drf/api.py index 43765f822..fddd37939 100644 --- a/apps/common/drf/api.py +++ b/apps/common/drf/api.py @@ -1,4 +1,4 @@ -from rest_framework.viewsets import GenericViewSet, ModelViewSet +from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet, ViewSet from rest_framework_bulk import BulkModelViewSet from ..mixins.api import ( @@ -15,19 +15,23 @@ class CommonMixin(SerializerMixin, pass -class JmsGenericViewSet(CommonMixin, - GenericViewSet): +class JMSGenericViewSet(CommonMixin, GenericViewSet): pass -class JMSModelViewSet(CommonMixin, - ModelViewSet): +class JMSViewSet(CommonMixin, ViewSet): pass -class JMSBulkModelViewSet(CommonMixin, - AllowBulkDestroyMixin, - BulkModelViewSet): +class JMSModelViewSet(CommonMixin, ModelViewSet): + pass + + +class JMSReadOnlyModelViewSet(CommonMixin, ReadOnlyModelViewSet): + pass + + +class JMSBulkModelViewSet(CommonMixin, AllowBulkDestroyMixin, BulkModelViewSet): pass diff --git a/apps/common/management/__init__.py b/apps/common/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/common/management/commands/__init__.py b/apps/common/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/common/management/commands/restart.py b/apps/common/management/commands/restart.py new file mode 100644 index 000000000..57285f9c9 --- /dev/null +++ b/apps/common/management/commands/restart.py @@ -0,0 +1,6 @@ +from .services.command import BaseActionCommand, Action + + +class Command(BaseActionCommand): + help = 'Restart services' + action = Action.restart.value diff --git a/apps/common/management/commands/services/__init__.py b/apps/common/management/commands/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/common/management/commands/services/command.py b/apps/common/management/commands/services/command.py new file mode 100644 index 000000000..230d59e10 --- /dev/null +++ b/apps/common/management/commands/services/command.py @@ -0,0 +1,139 @@ +from django.core.management.base import BaseCommand, CommandError +from django.db.models import TextChoices +from .utils import ServicesUtil +from .hands import * + + +class Services(TextChoices): + gunicorn = 'gunicorn', 'gunicorn' + daphne = 'daphne', 'daphne' + celery_ansible = 'celery_ansible', 'celery_ansible' + celery_default = 'celery_default', 'celery_default' + beat = 'beat', 'beat' + flower = 'flower', 'flower' + ws = 'ws', 'ws' + web = 'web', 'web' + celery = 'celery', 'celery' + task = 'task', 'task' + all = 'all', 'all' + + @classmethod + def get_service_object_class(cls, name): + from . import services + services_map = { + cls.gunicorn.value: services.GunicornService, + cls.daphne: services.DaphneService, + cls.flower: services.FlowerService, + cls.celery_default: services.CeleryDefaultService, + cls.celery_ansible: services.CeleryAnsibleService, + cls.beat: services.BeatService + } + return services_map.get(name) + + @classmethod + def ws_services(cls): + return [cls.daphne] + + @classmethod + def web_services(cls): + return [cls.gunicorn, cls.daphne] + + @classmethod + def celery_services(cls): + return [cls.celery_ansible, cls.celery_default] + + @classmethod + def task_services(cls): + return cls.celery_services() + [cls.beat, cls.flower] + + @classmethod + def all_services(cls): + return cls.web_services() + cls.task_services() + + @classmethod + def export_services_values(cls): + return [cls.all.value, cls.web.value, cls.task.value] + + @classmethod + def get_service_objects(cls, service_names, **kwargs): + services = set() + for name in service_names: + method_name = f'{name}_services' + if hasattr(cls, method_name): + _services = getattr(cls, method_name)() + elif hasattr(cls, name): + _services = [getattr(cls, name)] + else: + continue + services.update(set(_services)) + + service_objects = [] + for s in services: + service_class = cls.get_service_object_class(s.value) + if not service_class: + continue + kwargs.update({ + 'name': s.value + }) + service_object = service_class(**kwargs) + service_objects.append(service_object) + return service_objects + + +class Action(TextChoices): + start = 'start', 'start' + status = 'status', 'status' + stop = 'stop', 'stop' + restart = 'restart', 'restart' + + +class BaseActionCommand(BaseCommand): + help = 'Service Base Command' + + action = None + util = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def add_arguments(self, parser): + parser.add_argument( + 'services', nargs='+', choices=Services.export_services_values(), help='Service', + ) + parser.add_argument('-d', '--daemon', nargs="?", const=True) + parser.add_argument('-w', '--worker', type=int, nargs="?", default=4) + parser.add_argument('-f', '--force', nargs="?", const=True) + + def initial_util(self, *args, **options): + service_names = options.get('services') + service_kwargs = { + 'worker_gunicorn': options.get('worker') + } + services = Services.get_service_objects(service_names=service_names, **service_kwargs) + + kwargs = { + 'services': services, + 'run_daemon': options.get('daemon', False), + 'stop_daemon': self.action == Action.stop.value and Services.all.value in service_names, + 'force_stop': options.get('force') or False, + } + self.util = ServicesUtil(**kwargs) + + def handle(self, *args, **options): + self.initial_util(*args, **options) + assert self.action in Action.values, f'The action {self.action} is not in the optional list' + _handle = getattr(self, f'_handle_{self.action}', lambda: None) + _handle() + + def _handle_start(self): + self.util.start_and_watch() + os._exit(0) + + def _handle_stop(self): + self.util.stop() + + def _handle_restart(self): + self.util.restart() + + def _handle_status(self): + self.util.show_status() diff --git a/apps/common/management/commands/services/hands.py b/apps/common/management/commands/services/hands.py new file mode 100644 index 000000000..eb6aba418 --- /dev/null +++ b/apps/common/management/commands/services/hands.py @@ -0,0 +1,26 @@ +import os +import sys +import logging +from django.conf import settings + +from apps.jumpserver.const import CONFIG + +try: + from apps.jumpserver import const + __version__ = const.VERSION +except ImportError as e: + print("Not found __version__: {}".format(e)) + print("Python is: ") + logging.info(sys.executable) + __version__ = 'Unknown' + sys.exit(1) + + +HTTP_HOST = CONFIG.HTTP_BIND_HOST or '127.0.0.1' +HTTP_PORT = CONFIG.HTTP_LISTEN_PORT or 8080 +WS_PORT = CONFIG.WS_LISTEN_PORT or 8082 +DEBUG = CONFIG.DEBUG or False +BASE_DIR = os.path.dirname(settings.BASE_DIR) +LOG_DIR = os.path.join(BASE_DIR, 'logs') +APPS_DIR = os.path.join(BASE_DIR, 'apps') +TMP_DIR = os.path.join(BASE_DIR, 'tmp') diff --git a/apps/common/management/commands/services/services/__init__.py b/apps/common/management/commands/services/services/__init__.py new file mode 100644 index 000000000..cceb9627c --- /dev/null +++ b/apps/common/management/commands/services/services/__init__.py @@ -0,0 +1,6 @@ +from .beat import * +from .celery_ansible import * +from .celery_default import * +from .daphne import * +from .flower import * +from .gunicorn import * diff --git a/apps/common/management/commands/services/services/base.py b/apps/common/management/commands/services/services/base.py new file mode 100644 index 000000000..5063fb92e --- /dev/null +++ b/apps/common/management/commands/services/services/base.py @@ -0,0 +1,204 @@ +import abc +import time +import shutil +import psutil +import datetime +import threading +import subprocess +from ..hands import * + + +class BaseService(object): + + def __init__(self, **kwargs): + self.name = kwargs['name'] + self._process = None + self.STOP_TIMEOUT = 10 + self.max_retry = 0 + self.retry = 3 + self.LOG_KEEP_DAYS = 7 + self.EXIT_EVENT = threading.Event() + + @property + @abc.abstractmethod + def cmd(self): + return [] + + @property + @abc.abstractmethod + def cwd(self): + return '' + + @property + def is_running(self): + if self.pid == 0: + return False + try: + os.kill(self.pid, 0) + except (OSError, ProcessLookupError): + return False + else: + return True + + def show_status(self): + if self.is_running: + msg = f'{self.name} is running: {self.pid}.' + else: + msg = f'{self.name} is stopped.' + print(msg) + + # -- log -- + @property + def log_filename(self): + return f'{self.name}.log' + + @property + def log_filepath(self): + return os.path.join(LOG_DIR, self.log_filename) + + @property + def log_file(self): + return open(self.log_filepath, 'a') + + @property + def log_dir(self): + return os.path.dirname(self.log_filepath) + # -- end log -- + + # -- pid -- + @property + def pid_filepath(self): + return os.path.join(TMP_DIR, f'{self.name}.pid') + + @property + def pid(self): + if not os.path.isfile(self.pid_filepath): + return 0 + with open(self.pid_filepath) as f: + try: + pid = int(f.read().strip()) + except ValueError: + pid = 0 + return pid + + def write_pid(self): + with open(self.pid_filepath, 'w') as f: + f.write(str(self.process.pid)) + + def remove_pid(self): + if os.path.isfile(self.pid_filepath): + os.unlink(self.pid_filepath) + # -- end pid -- + + # -- process -- + @property + def process(self): + if not self._process: + try: + self._process = psutil.Process(self.pid) + except: + pass + return self._process + + # -- end process -- + + # -- action -- + def open_subprocess(self): + kwargs = {'cwd': self.cwd, 'stderr': self.log_file, 'stdout': self.log_file} + self._process = subprocess.Popen(self.cmd, **kwargs) + + def start(self): + if self.is_running: + self.show_status() + return + self.remove_pid() + self.open_subprocess() + self.write_pid() + self.start_other() + + def start_other(self): + pass + + def stop(self, force=False): + if not self.is_running: + self.show_status() + # self.remove_pid() + return + + print(f'Stop service: {self.name}', end='') + sig = 9 if force else 15 + os.kill(self.pid, sig) + + if self.process is None: + print("\033[31m No process found\033[0m") + return + try: + self.process.wait(1) + except: + pass + + for i in range(self.STOP_TIMEOUT): + if i == self.STOP_TIMEOUT - 1: + print("\033[31m Error\033[0m") + if not self.is_running: + print("\033[32m Ok\033[0m") + self.remove_pid() + break + else: + time.sleep(1) + continue + + def watch(self): + self._check() + if not self.is_running: + self._restart() + self._rotate_log() + + def _check(self): + now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f"{now} Check service status: {self.name} -> ", end='') + if self.process: + try: + self.process.wait(1) # 不wait,子进程可能无法回收 + except subprocess.TimeoutExpired: + pass + + if self.is_running: + print(f'running at {self.pid}') + else: + print(f'stopped at {self.pid}') + + def _restart(self): + if self.retry > self.max_retry: + logging.info("Service start failed, exit: ", self.name) + self.EXIT_EVENT.set() + return + self.retry += 1 + logging.info(f'> Find {self.name} stopped, retry {self.retry}, {self.pid}') + self.start() + + def _rotate_log(self): + now = datetime.datetime.now() + _time = now.strftime('%H:%M') + if _time != '23:59': + return + + backup_date = now.strftime('%Y-%m-%d') + backup_log_dir = os.path.join(self.log_dir, backup_date) + if not os.path.exists(backup_log_dir): + os.mkdir(backup_log_dir) + + backup_log_path = os.path.join(backup_log_dir, self.log_filename) + if os.path.isfile(self.log_filepath) and not os.path.isfile(backup_log_path): + logging.info(f'Rotate log file: {self.log_filepath} => {backup_log_path}') + shutil.copy(self.log_filepath, backup_log_path) + with open(self.log_filepath, 'w') as f: + pass + + to_delete_date = now - datetime.timedelta(days=self.LOG_KEEP_DAYS) + to_delete_dir = os.path.join(LOG_DIR, to_delete_date.strftime('%Y-%m-%d')) + if os.path.exists(to_delete_dir): + logging.info(f'Remove old log: {to_delete_dir}') + shutil.rmtree(to_delete_dir, ignore_errors=True) + # -- end action -- + diff --git a/apps/common/management/commands/services/services/beat.py b/apps/common/management/commands/services/services/beat.py new file mode 100644 index 000000000..de1f9f268 --- /dev/null +++ b/apps/common/management/commands/services/services/beat.py @@ -0,0 +1,25 @@ +from ..hands import * +from .base import BaseService +from django.core.cache import cache + + +__all__ = ['BeatService'] + + +class BeatService(BaseService): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.lock = cache.lock('beat-distribute-start-lock', expire=60) + + @property + def cmd(self): + print("\n- Start Beat as Periodic Task Scheduler") + cmd = [ + sys.executable, 'start_celery_beat.py', + ] + return cmd + + @property + def cwd(self): + return os.path.join(BASE_DIR, 'utils') diff --git a/apps/common/management/commands/services/services/celery_ansible.py b/apps/common/management/commands/services/services/celery_ansible.py new file mode 100644 index 000000000..a6c6608b7 --- /dev/null +++ b/apps/common/management/commands/services/services/celery_ansible.py @@ -0,0 +1,11 @@ +from .celery_base import CeleryBaseService + +__all__ = ['CeleryAnsibleService'] + + +class CeleryAnsibleService(CeleryBaseService): + + def __init__(self, **kwargs): + kwargs['queue'] = 'ansible' + super().__init__(**kwargs) + diff --git a/apps/common/management/commands/services/services/celery_base.py b/apps/common/management/commands/services/services/celery_base.py new file mode 100644 index 000000000..5542fd72f --- /dev/null +++ b/apps/common/management/commands/services/services/celery_base.py @@ -0,0 +1,38 @@ +from ..hands import * +from .base import BaseService + + +class CeleryBaseService(BaseService): + + def __init__(self, queue, num=10, **kwargs): + super().__init__(**kwargs) + self.queue = queue + self.num = num + + @property + def cmd(self): + print('\n- Start Celery as Distributed Task Queue: {}'.format(self.queue.capitalize())) + + os.environ.setdefault('PYTHONOPTIMIZE', '1') + os.environ.setdefault('ANSIBLE_FORCE_COLOR', 'True') + + if os.getuid() == 0: + os.environ.setdefault('C_FORCE_ROOT', '1') + server_hostname = os.environ.get("SERVER_HOSTNAME") + if not server_hostname: + server_hostname = '%h' + + cmd = [ + 'celery', 'worker', + '-P', 'threads', + '-A', 'ops', + '-l', 'INFO', + '-c', str(self.num), + '-Q', self.queue, + '-n', f'{self.queue}@{server_hostname}' + ] + return cmd + + @property + def cwd(self): + return APPS_DIR diff --git a/apps/common/management/commands/services/services/celery_default.py b/apps/common/management/commands/services/services/celery_default.py new file mode 100644 index 000000000..ad3d69fe1 --- /dev/null +++ b/apps/common/management/commands/services/services/celery_default.py @@ -0,0 +1,16 @@ +from .celery_base import CeleryBaseService + +__all__ = ['CeleryDefaultService'] + + +class CeleryDefaultService(CeleryBaseService): + + def __init__(self, **kwargs): + kwargs['queue'] = 'celery' + super().__init__(**kwargs) + + def start_other(self): + from terminal.startup import CeleryTerminal + celery_terminal = CeleryTerminal() + celery_terminal.start_heartbeat_thread() + diff --git a/apps/common/management/commands/services/services/daphne.py b/apps/common/management/commands/services/services/daphne.py new file mode 100644 index 000000000..09dd337a6 --- /dev/null +++ b/apps/common/management/commands/services/services/daphne.py @@ -0,0 +1,25 @@ +from ..hands import * +from .base import BaseService + +__all__ = ['DaphneService'] + + +class DaphneService(BaseService): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @property + def cmd(self): + print("\n- Start Daphne ASGI WS Server") + + cmd = [ + 'daphne', 'jumpserver.asgi:application', + '-b', HTTP_HOST, + '-p', str(WS_PORT), + ] + return cmd + + @property + def cwd(self): + return APPS_DIR diff --git a/apps/common/management/commands/services/services/flower.py b/apps/common/management/commands/services/services/flower.py new file mode 100644 index 000000000..df2230776 --- /dev/null +++ b/apps/common/management/commands/services/services/flower.py @@ -0,0 +1,31 @@ +from ..hands import * +from .base import BaseService + +__all__ = ['FlowerService'] + + +class FlowerService(BaseService): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + @property + def cmd(self): + print("\n- Start Flower as Task Monitor") + + if os.getuid() == 0: + os.environ.setdefault('C_FORCE_ROOT', '1') + cmd = [ + 'celery', 'flower', + '-A', 'ops', + '-l', 'INFO', + '--url_prefix=/core/flower', + '--auto_refresh=False', + '--max_tasks=1000', + '--tasks_columns=uuid,name,args,state,received,started,runtime,worker' + ] + return cmd + + @property + def cwd(self): + return APPS_DIR diff --git a/apps/common/management/commands/services/services/gunicorn.py b/apps/common/management/commands/services/services/gunicorn.py new file mode 100644 index 000000000..bfaeea8c4 --- /dev/null +++ b/apps/common/management/commands/services/services/gunicorn.py @@ -0,0 +1,40 @@ +from ..hands import * +from .base import BaseService + +__all__ = ['GunicornService'] + + +class GunicornService(BaseService): + + def __init__(self, **kwargs): + self.worker = kwargs['worker_gunicorn'] + super().__init__(**kwargs) + + @property + def cmd(self): + print("\n- Start Gunicorn WSGI HTTP Server") + + log_format = '%(h)s %(t)s %(L)ss "%(r)s" %(s)s %(b)s ' + bind = f'{HTTP_HOST}:{HTTP_PORT}' + cmd = [ + 'gunicorn', 'jumpserver.wsgi', + '-b', bind, + '-k', 'gthread', + '--threads', '10', + '-w', str(self.worker), + '--max-requests', '4096', + '--access-logformat', log_format, + '--access-logfile', '-' + ] + if DEBUG: + cmd.append('--reload') + return cmd + + @property + def cwd(self): + return APPS_DIR + + def start_other(self): + from terminal.startup import CoreTerminal + core_terminal = CoreTerminal() + core_terminal.start_heartbeat_thread() diff --git a/apps/common/management/commands/services/utils.py b/apps/common/management/commands/services/utils.py new file mode 100644 index 000000000..a5c34d770 --- /dev/null +++ b/apps/common/management/commands/services/utils.py @@ -0,0 +1,140 @@ +import threading +import signal +import time +import daemon +from daemon import pidfile +from .hands import * +from .hands import __version__ +from .services.base import BaseService + + +class ServicesUtil(object): + + def __init__(self, services, run_daemon=False, force_stop=False, stop_daemon=False): + self._services = services + self.run_daemon = run_daemon + self.force_stop = force_stop + self.stop_daemon = stop_daemon + self.EXIT_EVENT = threading.Event() + self.check_interval = 30 + self.files_preserve_map = {} + + def restart(self): + self.stop() + time.sleep(5) + self.start_and_watch() + + def start_and_watch(self): + logging.info(time.ctime()) + logging.info(f'JumpServer version {__version__}, more see https://www.jumpserver.org') + self.start() + if self.run_daemon: + self.show_status() + with self.daemon_context: + self.watch() + else: + self.watch() + + def start(self): + for service in self._services: + service: BaseService + service.start() + self.files_preserve_map[service.name] = service.log_file + time.sleep(1) + + def stop(self): + for service in self._services: + service: BaseService + service.stop(force=self.force_stop) + + if self.stop_daemon: + self._stop_daemon() + + # -- watch -- + def watch(self): + while not self.EXIT_EVENT.is_set(): + try: + _exit = self._watch() + if _exit: + break + time.sleep(self.check_interval) + except KeyboardInterrupt: + print('Start stop services') + break + self.clean_up() + + def _watch(self): + for service in self._services: + service: BaseService + service.watch() + if service.EXIT_EVENT.is_set(): + self.EXIT_EVENT.set() + return True + return False + # -- end watch -- + + def clean_up(self): + if not self.EXIT_EVENT.is_set(): + self.EXIT_EVENT.set() + + self.stop() + + def show_status(self): + for service in self._services: + service: BaseService + service.show_status() + + # -- daemon -- + def _stop_daemon(self): + if self.daemon_pid and self.daemon_is_running: + os.kill(self.daemon_pid, 15) + self.remove_daemon_pid() + + def remove_daemon_pid(self): + if os.path.isfile(self.daemon_pid_filepath): + os.unlink(self.daemon_pid_filepath) + + @property + def daemon_pid(self): + if not os.path.isfile(self.daemon_pid_filepath): + return 0 + with open(self.daemon_pid_filepath) as f: + try: + pid = int(f.read().strip()) + except ValueError: + pid = 0 + return pid + + @property + def daemon_is_running(self): + try: + os.kill(self.daemon_pid, 0) + except (OSError, ProcessLookupError): + return False + else: + return True + + @property + def daemon_pid_filepath(self): + return os.path.join(TMP_DIR, 'jms.pid') + + @property + def daemon_log_filepath(self): + return os.path.join(LOG_DIR, 'jms.log') + + @property + def daemon_context(self): + daemon_log_file = open(self.daemon_log_filepath, 'a') + context = daemon.DaemonContext( + pidfile=pidfile.TimeoutPIDLockFile(self.daemon_pid_filepath), + signal_map={ + signal.SIGTERM: lambda x, y: self.clean_up(), + signal.SIGHUP: 'terminate', + }, + stdout=daemon_log_file, + stderr=daemon_log_file, + files_preserve=list(self.files_preserve_map.values()), + detach_process=True, + ) + return context + # -- end daemon -- diff --git a/apps/common/management/commands/start.py b/apps/common/management/commands/start.py new file mode 100644 index 000000000..4c078a876 --- /dev/null +++ b/apps/common/management/commands/start.py @@ -0,0 +1,6 @@ +from .services.command import BaseActionCommand, Action + + +class Command(BaseActionCommand): + help = 'Start services' + action = Action.start.value diff --git a/apps/common/management/commands/status.py b/apps/common/management/commands/status.py new file mode 100644 index 000000000..36f0d3608 --- /dev/null +++ b/apps/common/management/commands/status.py @@ -0,0 +1,6 @@ +from .services.command import BaseActionCommand, Action + + +class Command(BaseActionCommand): + help = 'Show services status' + action = Action.status.value diff --git a/apps/common/management/commands/stop.py b/apps/common/management/commands/stop.py new file mode 100644 index 000000000..a79a5335c --- /dev/null +++ b/apps/common/management/commands/stop.py @@ -0,0 +1,6 @@ +from .services.command import BaseActionCommand, Action + + +class Command(BaseActionCommand): + help = 'Stop services' + action = Action.stop.value diff --git a/apps/common/message/backends/dingtalk/__init__.py b/apps/common/message/backends/dingtalk/__init__.py index 0ca9d5dc5..e98bdee04 100644 --- a/apps/common/message/backends/dingtalk/__init__.py +++ b/apps/common/message/backends/dingtalk/__init__.py @@ -2,8 +2,7 @@ import time import hmac import base64 -from common.message.backends.utils import request -from common.message.backends.utils import digest +from common.message.backends.utils import digest, as_request from common.message.backends.mixin import BaseRequest @@ -34,7 +33,7 @@ class URL: class DingTalkRequests(BaseRequest): - invalid_token_errcode = ErrorCode.INVALID_TOKEN + invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,) def __init__(self, appid, appsecret, agentid, timeout=None): self._appid = appid @@ -55,21 +54,33 @@ class DingTalkRequests(BaseRequest): expires_in = data['expires_in'] return access_token, expires_in - @request + def add_token(self, kwargs: dict): + params = kwargs.get('params') + if params is None: + params = {} + kwargs['params'] = params + params['access_token'] = self.access_token + def get(self, url, params=None, with_token=False, with_sign=False, check_errcode_is_0=True, **kwargs): pass + get = as_request(get) - @request def post(self, url, json=None, params=None, with_token=False, with_sign=False, check_errcode_is_0=True, **kwargs): pass + post = as_request(post) + + def _add_sign(self, kwargs: dict): + params = kwargs.get('params') + if params is None: + params = {} + kwargs['params'] = params - def _add_sign(self, params: dict): timestamp = str(int(time.time() * 1000)) signature = sign(self._appsecret, timestamp) accessKey = self._appid @@ -78,23 +89,17 @@ class DingTalkRequests(BaseRequest): params['signature'] = signature params['accessKey'] = accessKey - def request(self, method, url, params=None, + def request(self, method, url, with_token=False, with_sign=False, check_errcode_is_0=True, **kwargs): - if not isinstance(params, dict): - params = {} - - if with_token: - params['access_token'] = self.access_token if with_sign: - self._add_sign(params) - - data = self.raw_request(method, url, params=params, **kwargs) - if check_errcode_is_0: - self.check_errcode_is_0(data) + self._add_sign(kwargs) + data = super().request( + method, url, with_token=with_token, + check_errcode_is_0=check_errcode_is_0, **kwargs) return data diff --git a/apps/common/message/backends/feishu/__init__.py b/apps/common/message/backends/feishu/__init__.py new file mode 100644 index 000000000..7f70fd35d --- /dev/null +++ b/apps/common/message/backends/feishu/__init__.py @@ -0,0 +1,114 @@ +import json + +from django.utils.translation import ugettext_lazy as _ +from rest_framework.exceptions import APIException + +from common.utils.common import get_logger +from common.message.backends.utils import digest +from common.message.backends.mixin import RequestMixin, BaseRequest + +logger = get_logger(__name__) + + +class URL: + AUTHEN = 'https://open.feishu.cn/open-apis/authen/v1/index' + + GET_TOKEN = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/' + + # https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN + GET_USER_INFO_BY_CODE = 'https://open.feishu.cn/open-apis/authen/v1/access_token' + + SEND_MESSAGE = 'https://open.feishu.cn/open-apis/im/v1/messages' + + +class ErrorCode: + INVALID_APP_ACCESS_TOKEN = 99991664 + INVALID_USER_ACCESS_TOKEN = 99991668 + INVALID_TENANT_ACCESS_TOKEN = 99991663 + + +class FeishuRequests(BaseRequest): + """ + 处理系统级错误,抛出 API 异常,直接生成 HTTP 响应,业务代码无需关心这些错误 + - 确保 status_code == 200 + - 确保 access_token 无效时重试 + """ + invalid_token_errcodes = ( + ErrorCode.INVALID_USER_ACCESS_TOKEN, ErrorCode.INVALID_TENANT_ACCESS_TOKEN, + ErrorCode.INVALID_APP_ACCESS_TOKEN + ) + code_key = 'code' + msg_key = 'msg' + + def __init__(self, app_id, app_secret, timeout=None): + self._app_id = app_id + self._app_secret = app_secret + + super().__init__(timeout=timeout) + + def get_access_token_cache_key(self): + return digest(self._app_id, self._app_secret) + + def request_access_token(self): + data = {'app_id': self._app_id, 'app_secret': self._app_secret} + response = self.raw_request('post', url=URL.GET_TOKEN, data=data) + self.check_errcode_is_0(response) + + access_token = response['tenant_access_token'] + expires_in = response['expire'] + return access_token, expires_in + + def add_token(self, kwargs: dict): + headers = kwargs.setdefault('headers', {}) + headers['Authorization'] = f'Bearer {self.access_token}' + + +class FeiShu(RequestMixin): + """ + 非业务数据导致的错误直接抛异常,说明是系统配置错误,业务代码不用理会 + """ + + def __init__(self, app_id, app_secret, timeout=None): + self._app_id = app_id + self._app_secret = app_secret + + self._requests = FeishuRequests( + app_id=app_id, + app_secret=app_secret, + timeout=timeout + ) + + def get_user_id_by_code(self, code): + # https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN + + body = { + 'grant_type': 'authorization_code', + 'code': code + } + + data = self._requests.post(URL.GET_USER_INFO_BY_CODE, json=body, check_errcode_is_0=False) + + self._requests.check_errcode_is_0(data) + return data['data']['user_id'] + + def send_text(self, user_ids, msg): + params = { + 'receive_id_type': 'user_id' + } + + body = { + 'msg_type': 'text', + 'content': json.dumps({'text': msg}) + } + + invalid_users = [] + for user_id in user_ids: + body['receive_id'] = user_id + + try: + self._requests.post(URL.SEND_MESSAGE, params=params, json=body) + except APIException as e: + # 只处理可预知的错误 + logger.exception(e) + invalid_users.append(user_id) + return invalid_users diff --git a/apps/common/message/backends/mixin.py b/apps/common/message/backends/mixin.py index 5652a1520..af151a536 100644 --- a/apps/common/message/backends/mixin.py +++ b/apps/common/message/backends/mixin.py @@ -6,7 +6,7 @@ from django.core.cache import cache from .utils import DictWrapper from common.utils.common import get_logger from common.utils import lazyproperty -from common.message.backends.utils import set_default +from common.message.backends.utils import set_default, as_request from . import exceptions as exce @@ -14,17 +14,37 @@ logger = get_logger(__name__) class RequestMixin: - def check_errcode_is_0(self, data: DictWrapper): - errcode = data['errcode'] + code_key: str + msg_key: str + + +class BaseRequest(RequestMixin): + """ + 定义了 `access_token` 的过期刷新框架 + """ + invalid_token_errcodes = () + code_key = 'errcode' + msg_key = 'err_msg' + + def __init__(self, timeout=None): + self._request_kwargs = { + 'timeout': timeout + } + self.init_access_token() + + @classmethod + def check_errcode_is_0(cls, data: DictWrapper): + errcode = data[cls.code_key] if errcode != 0: # 如果代码写的对,配置没问题,这里不该出错,系统性错误,直接抛异常 - errmsg = data['errmsg'] + errmsg = data[cls.msg_key] logger.error(f'Response 200 but errcode is not 0: ' f'errcode={errcode} ' f'errmsg={errmsg} ') raise exce.ErrCodeNot0(detail=data.raw_data) - def check_http_is_200(self, response): + @staticmethod + def check_http_is_200(response): if response.status_code != 200: # 正常情况下不会返回非 200 响应码 logger.error(f'Response error: ' @@ -33,25 +53,28 @@ class RequestMixin: f'\ncontent={response.content}') raise exce.HTTPNot200(detail=response.json()) - -class BaseRequest(RequestMixin): - invalid_token_errcode = -1 - - def __init__(self, timeout=None): - self._request_kwargs = { - 'timeout': timeout - } - self.init_access_token() - def request_access_token(self): + """ + 获取新的 `access_token` 的方法,子类需要实现 + """ raise NotImplementedError def get_access_token_cache_key(self): + """ + 获取 `access_token` 的缓存 key, 子类需要实现 + """ + raise NotImplementedError + + def add_token(self, kwargs: dict): + """ + 添加 token ,子类需要实现 + """ raise NotImplementedError def is_token_invalid(self, data): - errcode = data['errcode'] - if errcode == self.invalid_token_errcode: + code = data[self.code_key] + if code in self.invalid_token_errcodes: + logger.error(f'OAuth token invalid: {data}') return True return False @@ -69,26 +92,58 @@ class BaseRequest(RequestMixin): def refresh_access_token(self): access_token, expires_in = self.request_access_token() self.access_token = access_token - cache.set(self.access_token_cache_key, access_token, expires_in) + cache.set(self.access_token_cache_key, access_token, expires_in - 10) def raw_request(self, method, url, **kwargs): set_default(kwargs, self._request_kwargs) - raw_data = '' + try: + response = getattr(requests, method)(url, **kwargs) + self.check_http_is_200(response) + raw_data = response.json() + data = DictWrapper(raw_data) + + return data + except req_exce.ReadTimeout as e: + logger.exception(e) + raise exce.NetError + + def token_request(self, method, url, **kwargs): for i in range(3): # 循环为了防止 access_token 失效 - try: - response = getattr(requests, method)(url, **kwargs) - self.check_http_is_200(response) - raw_data = response.json() - data = DictWrapper(raw_data) + self.add_token(kwargs) + data = self.raw_request(method, url, **kwargs) - if self.is_token_invalid(data): - self.refresh_access_token() - continue + if self.is_token_invalid(data): + self.refresh_access_token() + continue - return data - except req_exce.ReadTimeout as e: - logger.exception(e) - raise exce.NetError - logger.error(f'Get access_token error, check config: url={url} data={raw_data}') - raise PermissionDenied(raw_data) + return data + logger.error(f'Get access_token error, check config: url={url} data={data.raw_data}') + raise PermissionDenied(data.raw_data) + + def get(self, url, params=None, with_token=True, + check_errcode_is_0=True, **kwargs): + # self.request ... + pass + get = as_request(get) + + def post(self, url, params=None, json=None, + with_token=True, check_errcode_is_0=True, + **kwargs): + # self.request ... + pass + post = as_request(post) + + def request(self, method, url, + with_token=True, + check_errcode_is_0=True, + **kwargs): + + if with_token: + data = self.token_request(method, url, **kwargs) + else: + data = self.raw_request(method, url, **kwargs) + + if check_errcode_is_0: + self.check_errcode_is_0(data) + return data diff --git a/apps/common/message/backends/utils.py b/apps/common/message/backends/utils.py index 5a2f90355..1a1a3fe8c 100644 --- a/apps/common/message/backends/utils.py +++ b/apps/common/message/backends/utils.py @@ -54,7 +54,7 @@ class DictWrapper: return str(self.raw_data) -def request(func): +def as_request(func): def inner(*args, **kwargs): signature = inspect.signature(func) bound_args = signature.bind(*args, **kwargs) diff --git a/apps/common/message/backends/wecom/__init__.py b/apps/common/message/backends/wecom/__init__.py index dd3e34c8a..661a8276c 100644 --- a/apps/common/message/backends/wecom/__init__.py +++ b/apps/common/message/backends/wecom/__init__.py @@ -2,13 +2,9 @@ from typing import Iterable, AnyStr from django.utils.translation import ugettext_lazy as _ from rest_framework.exceptions import APIException -from requests.exceptions import ReadTimeout -import requests -from django.core.cache import cache from common.utils.common import get_logger from common.message.backends.utils import digest, DictWrapper, update_values, set_default -from common.message.backends.utils import request from common.message.backends.mixin import RequestMixin, BaseRequest logger = get_logger(__name__) @@ -48,7 +44,7 @@ class WeComRequests(BaseRequest): - 确保 status_code == 200 - 确保 access_token 无效时重试 """ - invalid_token_errcode = ErrorCode.INVALID_TOKEN + invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,) def __init__(self, corpid, corpsecret, agentid, timeout=None): self._corpid = corpid @@ -68,35 +64,13 @@ class WeComRequests(BaseRequest): expires_in = data['expires_in'] return access_token, expires_in - @request - def get(self, url, params=None, with_token=True, - check_errcode_is_0=True, **kwargs): - # self.request ... - pass - - @request - def post(self, url, params=None, json=None, - with_token=True, check_errcode_is_0=True, - **kwargs): - # self.request ... - pass - - def request(self, method, url, - params=None, - with_token=True, - check_errcode_is_0=True, - **kwargs): - - if not isinstance(params, dict): + def add_token(self, kwargs: dict): + params = kwargs.get('params') + if params is None: params = {} + kwargs['params'] = params - if with_token: - params['access_token'] = self.access_token - - data = self.raw_request(method, url, params=params, **kwargs) - if check_errcode_is_0: - self.check_errcode_is_0(data) - return data + params['access_token'] = self.access_token class WeCom(RequestMixin): @@ -147,7 +121,7 @@ class WeCom(RequestMixin): if errcode in (ErrorCode.RECIPIENTS_INVALID, ErrorCode.RECIPIENTS_EMPTY): # 全部接收人无权限或不存在 return users - self.check_errcode_is_0(data) + self._requests.check_errcode_is_0(data) invaliduser = data['invaliduser'] if not invaliduser: @@ -173,7 +147,7 @@ class WeCom(RequestMixin): logger.warn(f'WeCom get_user_id_by_code invalid code: code={code}') return None, None - self.check_errcode_is_0(data) + self._requests.check_errcode_is_0(data) USER_ID = 'UserId' OPEN_ID = 'OpenId' diff --git a/apps/common/mixins/api.py b/apps/common/mixins/api.py index 13323df0d..f7eb35dc2 100644 --- a/apps/common/mixins/api.py +++ b/apps/common/mixins/api.py @@ -24,6 +24,7 @@ from ..utils import lazyproperty __all__ = [ 'JSONResponseMixin', 'CommonApiMixin', 'AsyncApiMixin', 'RelationMixin', 'QuerySetMixin', 'ExtraFilterFieldsMixin', 'RenderToJsonMixin', + 'SerializerMixin', 'AllowBulkDestroyMixin', 'PaginatedResponseMixin' ] diff --git a/apps/common/permissions.py b/apps/common/permissions.py index 1fced6478..176455f66 100644 --- a/apps/common/permissions.py +++ b/apps/common/permissions.py @@ -112,6 +112,9 @@ class UserCanUpdateSSHKey(permissions.BasePermission): class NeedMFAVerify(permissions.BasePermission): def has_permission(self, request, view): + if not settings.SECURITY_VIEW_AUTH_NEED_MFA: + return True + mfa_verify_time = request.session.get('MFA_VERIFY_TIME', 0) if time.time() - mfa_verify_time < settings.SECURITY_MFA_VERIFY_TTL: return True diff --git a/apps/common/request_log.py b/apps/common/request_log.py deleted file mode 100644 index c35e6b84a..000000000 --- a/apps/common/request_log.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.http.request import HttpRequest -from django.http.response import HttpResponse - -from orgs.utils import current_org - - -class RequestLogMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request: HttpRequest): - print(f'Request {request.method} --> ', request.get_raw_uri()) - response: HttpResponse = self.get_response(request) - print(f'Response {current_org.name} {request.method} {response.status_code} --> ', request.get_raw_uri()) - return response diff --git a/apps/common/utils/__init__.py b/apps/common/utils/__init__.py index 0fdcf0dd1..b55f22af1 100644 --- a/apps/common/utils/__init__.py +++ b/apps/common/utils/__init__.py @@ -8,4 +8,5 @@ from .http import * from .ipip import * from .crypto import * from .random import * -from .jumpserver import * \ No newline at end of file +from .jumpserver import * +from .ip import * diff --git a/apps/common/utils/common.py b/apps/common/utils/common.py index 0e910cf16..6aaebaab1 100644 --- a/apps/common/utils/common.py +++ b/apps/common/utils/common.py @@ -7,8 +7,6 @@ import logging import datetime import uuid from functools import wraps -import string -import random import time import ipaddress import psutil @@ -242,11 +240,20 @@ class lazyproperty: return value -def get_disk_usage(): - partitions = psutil.disk_partitions() - mount_points = [p.mountpoint for p in partitions] - usages = {p: psutil.disk_usage(p) for p in mount_points} - return usages +def get_disk_usage(path): + return psutil.disk_usage(path=path).percent + + +def get_cpu_load(): + cpu_load_1, cpu_load_5, cpu_load_15 = psutil.getloadavg() + cpu_count = psutil.cpu_count() + single_cpu_load_1 = cpu_load_1 / cpu_count + single_cpu_load_1 = '%.2f' % single_cpu_load_1 + return float(single_cpu_load_1) + + +def get_memory_usage(): + return psutil.virtual_memory().percent class Time: @@ -273,3 +280,17 @@ def bulk_get(d, *keys, default=None): for key in keys: values.append(d.get(key, default)) return values + + +def unique(objects, key=None): + seen = OrderedDict() + + if key is None: + key = lambda item: item + + for obj in objects: + v = key(obj) + if v not in seen: + seen[v] = obj + return list(seen.values()) + diff --git a/apps/common/utils/ip.py b/apps/common/utils/ip.py new file mode 100644 index 000000000..0000a5da7 --- /dev/null +++ b/apps/common/utils/ip.py @@ -0,0 +1,68 @@ +from ipaddress import ip_network, ip_address + + +def is_ip_address(address): + """ 192.168.10.1 """ + try: + ip_address(address) + except ValueError: + return False + else: + return True + + +def is_ip_network(ip): + """ 192.168.1.0/24 """ + try: + ip_network(ip) + except ValueError: + return False + else: + return True + + +def is_ip_segment(ip): + """ 10.1.1.1-10.1.1.20 """ + if '-' not in ip: + return False + ip_address1, ip_address2 = ip.split('-') + return is_ip_address(ip_address1) and is_ip_address(ip_address2) + + +def in_ip_segment(ip, ip_segment): + ip1, ip2 = ip_segment.split('-') + ip1 = int(ip_address(ip1)) + ip2 = int(ip_address(ip2)) + ip = int(ip_address(ip)) + return min(ip1, ip2) <= ip <= max(ip1, ip2) + + +def contains_ip(ip, ip_group): + """ + ip_group: + [192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64.] + + """ + + if '*' in ip_group: + return True + + for _ip in ip_group: + if is_ip_address(_ip): + # 192.168.10.1 + if ip == _ip: + return True + elif is_ip_network(_ip) and is_ip_address(ip): + # 192.168.1.0/24 + if ip_address(ip) in ip_network(_ip): + return True + elif is_ip_segment(_ip) and is_ip_address(ip): + # 10.1.1.1-10.1.1.20 + if in_ip_segment(ip, _ip): + return True + else: + # is domain name + if ip == _ip: + return True + + return False diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 449ae7974..44f7711b7 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -16,9 +16,7 @@ import json import yaml from importlib import import_module from django.urls import reverse_lazy -from django.templatetags.static import static from urllib.parse import urljoin, urlparse -from django.utils.translation import ugettext_lazy as _ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_DIR = os.path.dirname(BASE_DIR) @@ -230,6 +228,10 @@ class Config(dict): 'DINGTALK_APPKEY': '', 'DINGTALK_APPSECRET': '', + 'AUTH_FEISHU': False, + 'FEISHU_APP_ID': '', + 'FEISHU_APP_SECRET': '', + 'OTP_VALID_WINDOW': 2, 'OTP_ISSUER_NAME': 'JumpServer', 'EMAIL_SUFFIX': 'jumpserver.org', @@ -244,7 +246,7 @@ class Config(dict): 'TERMINAL_TELNET_REGEX': '', 'TERMINAL_COMMAND_STORAGE': {}, - 'SECURITY_MFA_AUTH': False, + 'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启 'SECURITY_COMMAND_EXECUTION': True, 'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True, 'SECURITY_VIEW_AUTH_NEED_MFA': True, @@ -253,6 +255,7 @@ class Config(dict): 'SECURITY_MAX_IDLE_TIME': 30, 'SECURITY_PASSWORD_EXPIRATION_TIME': 9999, 'SECURITY_PASSWORD_MIN_LENGTH': 6, + 'SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH': 6, 'SECURITY_PASSWORD_UPPER_CASE': False, 'SECURITY_PASSWORD_LOWER_CASE': False, 'SECURITY_PASSWORD_NUMBER': False, @@ -264,6 +267,7 @@ class Config(dict): 'SECURITY_INSECURE_COMMAND_LEVEL': 5, 'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '', 'SECURITY_LUNA_REMEMBER_AUTH': True, + 'SECURITY_WATERMARK_ENABLED': False, 'HTTP_BIND_HOST': '0.0.0.0', 'HTTP_LISTEN_PORT': 8080, @@ -301,11 +305,11 @@ class Config(dict): 'CONNECTION_TOKEN_ENABLED': False, 'ONLY_ALLOW_EXIST_USER_AUTH': False, 'ONLY_ALLOW_AUTH_FROM_SOURCE': False, - 'DISK_CHECK_ENABLED': True, 'SESSION_SAVE_EVERY_REQUEST': True, 'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False, 'FORGOT_PASSWORD_URL': '', 'HEALTH_CHECK_TOKEN': '', + 'LOGIN_REDIRECT_TO_BACKEND': None, # 'OPENID / CAS 'TERMINAL_RDP_ADDR': '' } diff --git a/apps/jumpserver/context_processor.py b/apps/jumpserver/context_processor.py index 79b1e450f..4245abd48 100644 --- a/apps/jumpserver/context_processor.py +++ b/apps/jumpserver/context_processor.py @@ -16,6 +16,7 @@ def jumpserver_processor(request): 'LOGIN_CAS_LOGO_URL': static('img/login_cas_logo.png'), 'LOGIN_WECOM_LOGO_URL': static('img/login_wecom_logo.png'), 'LOGIN_DINGTALK_LOGO_URL': static('img/login_dingtalk_logo.png'), + 'LOGIN_FEISHU_LOGO_URL': static('img/login_feishu_logo.png'), 'JMS_TITLE': _('JumpServer Open Source Bastion Host'), 'VERSION': settings.VERSION, 'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2021', diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index 264566620..d8e96e673 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -117,6 +117,10 @@ DINGTALK_AGENTID = CONFIG.DINGTALK_AGENTID DINGTALK_APPKEY = CONFIG.DINGTALK_APPKEY DINGTALK_APPSECRET = CONFIG.DINGTALK_APPSECRET +# FeiShu auth +AUTH_FEISHU = CONFIG.AUTH_FEISHU +FEISHU_APP_ID = CONFIG.FEISHU_APP_ID +FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET # Other setting TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION @@ -134,12 +138,13 @@ AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend' AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication' AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication' AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication' +AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication' AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication' AUTHENTICATION_BACKENDS = [ AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM, - AUTH_BACKEND_DINGTALK, AUTH_BACKEND_AUTH_TOKEN + AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_AUTH_TOKEN, ] if AUTH_CAS: diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index c60c53788..722682bb0 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -38,6 +38,7 @@ SECURITY_LOGIN_LIMIT_TIME = CONFIG.SECURITY_LOGIN_LIMIT_TIME # Unit: minute SECURITY_MAX_IDLE_TIME = CONFIG.SECURITY_MAX_IDLE_TIME # Unit: minute SECURITY_PASSWORD_EXPIRATION_TIME = CONFIG.SECURITY_PASSWORD_EXPIRATION_TIME # Unit: day SECURITY_PASSWORD_MIN_LENGTH = CONFIG.SECURITY_PASSWORD_MIN_LENGTH # Unit: bit +SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH = CONFIG.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH # Unit: bit OLD_PASSWORD_HISTORY_LIMIT_COUNT = CONFIG.OLD_PASSWORD_HISTORY_LIMIT_COUNT SECURITY_PASSWORD_UPPER_CASE = CONFIG.SECURITY_PASSWORD_UPPER_CASE SECURITY_PASSWORD_LOWER_CASE = CONFIG.SECURITY_PASSWORD_LOWER_CASE @@ -118,7 +119,6 @@ TICKETS_ENABLED = CONFIG.TICKETS_ENABLED REFERER_CHECK_ENABLED = CONFIG.REFERER_CHECK_ENABLED CONNECTION_TOKEN_ENABLED = CONFIG.CONNECTION_TOKEN_ENABLED -DISK_CHECK_ENABLED = CONFIG.DISK_CHECK_ENABLED FORGOT_PASSWORD_URL = CONFIG.FORGOT_PASSWORD_URL @@ -128,3 +128,6 @@ HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN TERMINAL_RDP_ADDR = CONFIG.TERMINAL_RDP_ADDR SECURITY_LUNA_REMEMBER_AUTH = CONFIG.SECURITY_LUNA_REMEMBER_AUTH +SECURITY_WATERMARK_ENABLED = CONFIG.SECURITY_WATERMARK_ENABLED + +LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND diff --git a/apps/jumpserver/settings/libs.py b/apps/jumpserver/settings/libs.py index 639d542e7..9c67a15e8 100644 --- a/apps/jumpserver/settings/libs.py +++ b/apps/jumpserver/settings/libs.py @@ -40,8 +40,8 @@ REST_FRAMEWORK = { 'DEFAULT_METADATA_CLASS': 'common.drf.metadata.SimpleMetadataWithFilters', 'ORDERING_PARAM': "order", 'SEARCH_PARAM': "search", - 'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S %z', - 'DATETIME_INPUT_FORMATS': ['iso-8601', '%Y-%m-%d %H:%M:%S %z'], + 'DATETIME_FORMAT': '%Y/%m/%d %H:%M:%S %z', + 'DATETIME_INPUT_FORMATS': ['%Y/%m/%d %H:%M:%S %z', 'iso-8601', '%Y-%m-%d %H:%M:%S %z'], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'EXCEPTION_HANDLER': 'common.drf.exc_handlers.common_exception_handler', # 'PAGE_SIZE': 100, diff --git a/apps/jumpserver/views/celery_flower.py b/apps/jumpserver/views/celery_flower.py index befd3c5f0..0ec4a0fe1 100644 --- a/apps/jumpserver/views/celery_flower.py +++ b/apps/jumpserver/views/celery_flower.py @@ -20,7 +20,7 @@ def celery_flower_view(request, path): try: response = proxy_view(request, remote_url) except Exception as e: - msg = _("

Flow service unavailable, check it

") + \ + msg = _("

Flower service unavailable, check it

") + \ '

{}
'.format(e) response = HttpResponse(msg) return response diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index af2301013..7fca22dce 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 1922354d7..6736d2761 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-07-15 18:09+0800\n" +"POT-Creation-Date: 2021-08-12 10:27+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -18,14 +18,14 @@ msgstr "" "X-Generator: Poedit 2.4.3\n" #: acls/models/base.py:25 acls/serializers/login_asset_acl.py:47 -#: applications/models/application.py:11 assets/models/asset.py:139 -#: assets/models/base.py:172 assets/models/cluster.py:18 -#: assets/models/cmd_filter.py:21 assets/models/domain.py:22 +#: applications/models/application.py:166 assets/models/asset.py:139 +#: assets/models/base.py:175 assets/models/cluster.py:18 +#: assets/models/cmd_filter.py:21 assets/models/domain.py:24 #: assets/models/group.py:20 assets/models/label.py:18 ops/mixin.py:24 -#: orgs/models.py:23 perms/models/base.py:49 settings/models.py:29 +#: orgs/models.py:24 perms/models/base.py:49 settings/models.py:29 #: terminal/models/storage.py:23 terminal/models/task.py:16 #: terminal/models/terminal.py:100 users/forms/profile.py:32 -#: users/models/group.py:15 users/models/user.py:551 +#: users/models/group.py:15 users/models/user.py:556 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 @@ -35,12 +35,12 @@ msgid "Name" msgstr "名称" #: acls/models/base.py:27 assets/models/cmd_filter.py:54 -#: assets/models/user.py:202 +#: assets/models/user.py:203 msgid "Priority" msgstr "优先级" #: acls/models/base.py:28 assets/models/cmd_filter.py:54 -#: assets/models/user.py:202 +#: assets/models/user.py:203 msgid "1-100, the lower the value will be match first" msgstr "优先级可选范围为 1-100 (数值越小越优先)" @@ -52,17 +52,17 @@ msgstr "激活中" # msgid "Date created" # msgstr "创建日期" -#: acls/models/base.py:32 applications/models/application.py:24 +#: acls/models/base.py:32 applications/models/application.py:179 #: assets/models/asset.py:144 assets/models/asset.py:220 -#: assets/models/base.py:177 assets/models/cluster.py:29 +#: assets/models/base.py:180 assets/models/cluster.py:29 #: assets/models/cmd_filter.py:23 assets/models/cmd_filter.py:64 -#: assets/models/domain.py:23 assets/models/domain.py:54 +#: assets/models/domain.py:25 assets/models/domain.py:65 #: assets/models/group.py:23 assets/models/label.py:23 ops/models/adhoc.py:37 -#: orgs/models.py:26 perms/models/base.py:57 settings/models.py:34 +#: orgs/models.py:27 perms/models/base.py:57 settings/models.py:34 #: terminal/models/storage.py:26 terminal/models/terminal.py:114 #: tickets/models/ticket.py:73 users/models/group.py:16 -#: users/models/user.py:584 xpack/plugins/change_auth_plan/models.py:77 -#: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:98 +#: users/models/user.py:589 xpack/plugins/change_auth_plan/models.py:77 +#: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:108 #: xpack/plugins/gathered_user/models.py:26 msgid "Comment" msgstr "备注" @@ -92,14 +92,14 @@ msgstr "动作" #: acls/models/login_acl.py:28 acls/models/login_asset_acl.py:20 #: acls/serializers/login_acl.py:33 assets/models/label.py:15 -#: audits/models.py:36 audits/models.py:56 audits/models.py:69 +#: audits/models.py:36 audits/models.py:56 audits/models.py:74 #: audits/serializers.py:93 authentication/models.py:44 -#: authentication/models.py:97 orgs/models.py:18 orgs/models.py:418 +#: authentication/models.py:97 orgs/models.py:19 orgs/models.py:433 #: perms/models/base.py:50 templates/index.html:78 #: terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models/session.py:38 #: tickets/models/comment.py:17 users/models/user.py:176 -#: users/models/user.py:747 users/models/user.py:773 +#: users/models/user.py:757 users/models/user.py:783 #: users/serializers/group.py:19 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -118,16 +118,16 @@ msgid "System User" msgstr "系统用户" #: acls/models/login_asset_acl.py:22 -#: applications/serializers/attrs/application_category/remote_app.py:33 -#: assets/models/asset.py:350 assets/models/authbook.py:15 -#: assets/models/gathered_user.py:14 assets/serializers/system_user.py:196 +#: applications/serializers/attrs/application_category/remote_app.py:37 +#: assets/models/asset.py:357 assets/models/authbook.py:15 +#: assets/models/gathered_user.py:14 assets/serializers/system_user.py:200 #: audits/models.py:38 perms/models/asset_permission.py:99 #: templates/index.html:82 terminal/backends/command/models.py:19 #: terminal/backends/command/serializers.py:13 terminal/models/session.py:40 #: users/templates/users/user_asset_permission.html:40 #: users/templates/users/user_asset_permission.html:70 #: xpack/plugins/change_auth_plan/models.py:282 -#: xpack/plugins/cloud/models.py:202 +#: xpack/plugins/cloud/models.py:212 msgid "Asset" msgstr "资产" @@ -140,7 +140,7 @@ msgstr "审批人" msgid "Login asset confirm" msgstr "登录资产复核" -#: acls/serializers/login_acl.py:18 +#: acls/serializers/login_acl.py:18 xpack/plugins/cloud/serializers.py:165 msgid "IP address invalid: `{}`" msgstr "IP 地址无效: `{}`" @@ -155,8 +155,8 @@ msgstr "" #: acls/serializers/login_acl.py:30 acls/serializers/login_asset_acl.py:31 #: applications/serializers/attrs/application_type/mysql_workbench.py:18 -#: assets/models/asset.py:180 assets/models/domain.py:50 -#: assets/serializers/account.py:11 settings/serializers/settings.py:113 +#: assets/models/asset.py:180 assets/models/domain.py:61 +#: assets/serializers/account.py:12 settings/serializers/settings.py:114 #: users/templates/users/_granted_assets.html:26 #: users/templates/users/user_asset_permission.html:156 msgid "IP" @@ -172,17 +172,18 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " #: acls/serializers/login_asset_acl.py:17 #: acls/serializers/login_asset_acl.py:51 +#: applications/serializers/application.py:71 #: applications/serializers/attrs/application_type/chrome.py:20 #: applications/serializers/attrs/application_type/custom.py:21 #: applications/serializers/attrs/application_type/mysql_workbench.py:30 #: applications/serializers/attrs/application_type/vmware_client.py:26 -#: assets/models/base.py:173 assets/models/gathered_user.py:15 -#: audits/models.py:100 authentication/forms.py:15 authentication/forms.py:17 -#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:549 +#: assets/models/base.py:176 assets/models/gathered_user.py:15 +#: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17 +#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:554 #: users/templates/users/_select_user_modal.html:14 #: xpack/plugins/change_auth_plan/models.py:47 #: xpack/plugins/change_auth_plan/models.py:278 -#: xpack/plugins/cloud/serializers.py:65 +#: xpack/plugins/cloud/serializers.py:67 msgid "Username" msgstr "用户名" @@ -196,8 +197,8 @@ msgstr "" "10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64 (支持网域)" #: acls/serializers/login_asset_acl.py:35 assets/models/asset.py:181 -#: assets/serializers/account.py:12 assets/serializers/gathered_user.py:23 -#: settings/serializers/settings.py:112 +#: assets/serializers/account.py:13 assets/serializers/gathered_user.py:23 +#: settings/serializers/settings.py:113 #: users/templates/users/_granted_assets.html:25 #: users/templates/users/user_asset_permission.html:157 msgid "Hostname" @@ -210,7 +211,7 @@ msgid "" msgstr "格式为逗号分隔的字符串, * 表示匹配所有. 可选的协议有: {}" #: acls/serializers/login_asset_acl.py:55 assets/models/asset.py:184 -#: assets/models/domain.py:52 assets/models/user.py:203 +#: assets/models/domain.py:63 assets/models/user.py:204 #: terminal/serializers/session.py:30 terminal/serializers/storage.py:69 msgid "Protocol" msgstr "协议" @@ -228,31 +229,39 @@ msgstr "组织 `{}` 不存在" msgid "None of the reviewers belong to Organization `{}`" msgstr "所有复核人都不属于组织 `{}`" -#: applications/const.py:9 +#: applications/api/mixin.py:20 templates/_nav_user.html:10 +msgid "My applications" +msgstr "我的应用" + +#: applications/const.py:8 #: applications/serializers/attrs/application_category/db.py:14 #: applications/serializers/attrs/application_type/mysql_workbench.py:26 msgid "Database" msgstr "数据库" -#: applications/const.py:10 +#: applications/const.py:9 msgid "Remote app" msgstr "远程应用" -#: applications/const.py:29 +#: applications/const.py:28 msgid "Custom" msgstr "自定义" -#: applications/models/application.py:13 assets/models/label.py:21 +#: applications/models/application.py:50 templates/_nav.html:60 +msgid "Applications" +msgstr "应用管理" + +#: applications/models/application.py:168 +#: applications/serializers/application.py:78 assets/models/label.py:21 #: perms/models/application_permission.py:20 -#: perms/serializers/application/permission.py:16 #: perms/serializers/application/user_permission.py:33 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:20 msgid "Category" msgstr "类别" -#: applications/models/application.py:16 assets/models/cmd_filter.py:53 -#: assets/models/user.py:201 perms/models/application_permission.py:23 -#: perms/serializers/application/permission.py:17 +#: applications/models/application.py:171 +#: applications/serializers/application.py:80 assets/models/cmd_filter.py:53 +#: assets/models/user.py:202 perms/models/application_permission.py:23 #: perms/serializers/application/user_permission.py:34 #: terminal/models/storage.py:55 terminal/models/storage.py:116 #: tickets/models/ticket.py:38 @@ -260,47 +269,89 @@ msgstr "类别" msgid "Type" msgstr "类型" -#: applications/models/application.py:20 assets/models/asset.py:188 -#: assets/models/domain.py:28 assets/models/domain.py:53 +#: applications/models/application.py:175 assets/models/asset.py:188 +#: assets/models/domain.py:30 assets/models/domain.py:64 msgid "Domain" msgstr "网域" -#: applications/models/application.py:22 xpack/plugins/cloud/models.py:33 +#: applications/models/application.py:177 xpack/plugins/cloud/models.py:33 msgid "Attrs" msgstr "" #: applications/serializers/application.py:48 -msgid "Category(Display)" -msgstr "类别 (显示名称)" +#: applications/serializers/application.py:79 assets/serializers/label.py:13 +#: perms/serializers/application/permission.py:16 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:24 +msgid "Category display" +msgstr "类别名称" #: applications/serializers/application.py:49 -msgid "Type(Dispaly)" -msgstr "类型 (显示名称)" +#: applications/serializers/application.py:81 +#: assets/serializers/system_user.py:26 audits/serializers.py:29 +#: perms/serializers/application/permission.py:17 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:31 +#: tickets/serializers/ticket/ticket.py:19 +msgid "Type display" +msgstr "类型名称" #: applications/serializers/application.py:72 +#: applications/serializers/application.py:100 +#: applications/serializers/attrs/application_type/chrome.py:23 +#: applications/serializers/attrs/application_type/custom.py:25 +#: applications/serializers/attrs/application_type/mysql_workbench.py:34 +#: applications/serializers/attrs/application_type/vmware_client.py:30 +#: assets/models/base.py:177 audits/signals_handler.py:63 +#: authentication/forms.py:22 +#: authentication/templates/authentication/login.html:164 +#: settings/serializers/settings.py:95 users/forms/profile.py:21 +#: users/templates/users/user_otp_check_password.html:13 +#: users/templates/users/user_password_update.html:43 +#: users/templates/users/user_password_verify.html:18 +#: xpack/plugins/change_auth_plan/models.py:68 +#: xpack/plugins/change_auth_plan/models.py:190 +#: xpack/plugins/change_auth_plan/models.py:285 +#: xpack/plugins/cloud/serializers.py:69 +msgid "Password" +msgstr "密码" + +#: applications/serializers/application.py:73 assets/models/authbook.py:16 +#: assets/models/user.py:277 audits/models.py:39 +#: perms/models/application_permission.py:31 +#: perms/models/asset_permission.py:101 templates/_nav.html:45 +#: terminal/backends/command/models.py:20 +#: terminal/backends/command/serializers.py:14 terminal/models/session.py:42 +#: users/templates/users/_granted_assets.html:27 +#: users/templates/users/user_asset_permission.html:42 +#: users/templates/users/user_asset_permission.html:76 +#: users/templates/users/user_asset_permission.html:159 +#: users/templates/users/user_database_app_permission.html:40 +#: users/templates/users/user_database_app_permission.html:67 +msgid "System user" +msgstr "系统用户" + +#: applications/serializers/application.py:74 assets/serializers/account.py:31 +msgid "System user display" +msgstr "系统用户名称" + +#: applications/serializers/application.py:75 +msgid "App" +msgstr "应用" + +#: applications/serializers/application.py:76 +msgid "Union id" +msgstr "联合ID" + +#: applications/serializers/application.py:77 msgid "Application name" msgstr "应用名称" -#: applications/serializers/application.py:73 -msgid "Application category" -msgstr "应用类别" - -#: applications/serializers/application.py:74 -msgid "Application type" -msgstr "应用类型" - -#: applications/serializers/application.py:87 -#: assets/serializers/system_user.py:49 assets/serializers/system_user.py:197 -msgid "Login mode display" -msgstr "认证方式(显示名称)" - #: applications/serializers/attrs/application_category/cloud.py:9 #: assets/models/cluster.py:40 msgid "Cluster" msgstr "集群" #: applications/serializers/attrs/application_category/db.py:11 -#: ops/models/adhoc.py:146 xpack/plugins/cloud/serializers.py:63 +#: ops/models/adhoc.py:146 xpack/plugins/cloud/serializers.py:65 msgid "Host" msgstr "主机" @@ -309,12 +360,12 @@ msgstr "主机" #: applications/serializers/attrs/application_type/mysql_workbench.py:22 #: applications/serializers/attrs/application_type/oracle.py:11 #: applications/serializers/attrs/application_type/pgsql.py:11 -#: assets/models/asset.py:185 assets/models/domain.py:51 -#: xpack/plugins/cloud/serializers.py:64 +#: assets/models/asset.py:185 assets/models/domain.py:62 +#: xpack/plugins/cloud/serializers.py:66 msgid "Port" msgstr "端口" -#: applications/serializers/attrs/application_category/remote_app.py:36 +#: applications/serializers/attrs/application_category/remote_app.py:40 #: applications/serializers/attrs/application_type/chrome.py:14 #: applications/serializers/attrs/application_type/mysql_workbench.py:14 #: applications/serializers/attrs/application_type/vmware_client.py:18 @@ -326,24 +377,6 @@ msgstr "应用路径" msgid "Target URL" msgstr "目标URL" -#: applications/serializers/attrs/application_type/chrome.py:23 -#: applications/serializers/attrs/application_type/custom.py:25 -#: applications/serializers/attrs/application_type/mysql_workbench.py:34 -#: applications/serializers/attrs/application_type/vmware_client.py:30 -#: assets/models/base.py:174 audits/signals_handler.py:58 -#: authentication/forms.py:22 -#: authentication/templates/authentication/login.html:164 -#: settings/serializers/settings.py:94 users/forms/profile.py:21 -#: users/templates/users/user_otp_check_password.html:13 -#: users/templates/users/user_password_update.html:43 -#: users/templates/users/user_password_verify.html:18 -#: xpack/plugins/change_auth_plan/models.py:68 -#: xpack/plugins/change_auth_plan/models.py:190 -#: xpack/plugins/change_auth_plan/models.py:285 -#: xpack/plugins/cloud/serializers.py:67 -msgid "Password" -msgstr "密码" - #: applications/serializers/attrs/application_type/custom.py:13 msgid "Operating parameter" msgstr "运行参数" @@ -392,10 +425,11 @@ msgstr "系统平台" #: assets/models/asset.py:186 assets/serializers/asset.py:65 #: perms/serializers/asset/user_permission.py:41 +#: xpack/plugins/cloud/models.py:99 xpack/plugins/cloud/serializers.py:183 msgid "Protocols" msgstr "协议组" -#: assets/models/asset.py:189 assets/models/user.py:193 +#: assets/models/asset.py:189 assets/models/user.py:194 #: perms/models/asset_permission.py:100 #: xpack/plugins/change_auth_plan/models.py:56 #: xpack/plugins/gathered_user/models.py:24 @@ -403,16 +437,16 @@ msgid "Nodes" msgstr "节点" #: assets/models/asset.py:190 assets/models/cmd_filter.py:22 -#: assets/models/domain.py:55 assets/models/label.py:22 +#: assets/models/domain.py:66 assets/models/label.py:22 #: authentication/models.py:46 msgid "Is active" msgstr "激活" #: assets/models/asset.py:193 assets/models/cluster.py:19 -#: assets/models/user.py:190 assets/models/user.py:325 templates/_nav.html:44 -#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:179 +#: assets/models/user.py:191 assets/models/user.py:326 templates/_nav.html:44 +#: xpack/plugins/cloud/models.py:96 xpack/plugins/cloud/serializers.py:205 msgid "Admin user" -msgstr "管理用户" +msgstr "特权用户" #: assets/models/asset.py:196 msgid "Public IP" @@ -482,42 +516,28 @@ msgstr "主机名原始" msgid "Labels" msgstr "标签管理" -#: assets/models/asset.py:218 assets/models/base.py:180 +#: assets/models/asset.py:218 assets/models/base.py:183 #: assets/models/cluster.py:28 assets/models/cmd_filter.py:26 #: assets/models/cmd_filter.py:67 assets/models/group.py:21 -#: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:24 -#: orgs/models.py:422 perms/models/base.py:55 users/models/user.py:592 +#: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25 +#: orgs/models.py:437 perms/models/base.py:55 users/models/user.py:597 #: users/serializers/group.py:33 xpack/plugins/change_auth_plan/models.py:81 -#: xpack/plugins/cloud/models.py:104 xpack/plugins/gathered_user/models.py:30 +#: xpack/plugins/cloud/models.py:114 xpack/plugins/gathered_user/models.py:30 msgid "Created by" msgstr "创建者" # msgid "Created by" # msgstr "创建者" -#: assets/models/asset.py:219 assets/models/base.py:178 -#: assets/models/cluster.py:26 assets/models/domain.py:25 +#: assets/models/asset.py:219 assets/models/base.py:181 +#: assets/models/cluster.py:26 assets/models/domain.py:27 #: assets/models/gathered_user.py:19 assets/models/group.py:22 #: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50 -#: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:25 -#: orgs/models.py:420 perms/models/base.py:56 users/models/group.py:18 -#: users/models/user.py:774 xpack/plugins/cloud/models.py:107 +#: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26 +#: orgs/models.py:435 perms/models/base.py:56 users/models/group.py:18 +#: users/models/user.py:784 xpack/plugins/cloud/models.py:117 msgid "Date created" msgstr "创建日期" -#: assets/models/authbook.py:16 assets/models/user.py:276 audits/models.py:39 -#: perms/models/application_permission.py:31 -#: perms/models/asset_permission.py:101 templates/_nav.html:45 -#: terminal/backends/command/models.py:20 -#: terminal/backends/command/serializers.py:14 terminal/models/session.py:42 -#: users/templates/users/_granted_assets.html:27 -#: users/templates/users/user_asset_permission.html:42 -#: users/templates/users/user_asset_permission.html:76 -#: users/templates/users/user_asset_permission.html:159 -#: users/templates/users/user_database_app_permission.html:40 -#: users/templates/users/user_database_app_permission.html:67 -msgid "System user" -msgstr "系统用户" - #: assets/models/authbook.py:17 msgid "Version" msgstr "版本" @@ -534,7 +554,8 @@ msgstr "未知" msgid "Ok" msgstr "成功" -#: assets/models/base.py:32 audits/models.py:97 xpack/plugins/cloud/const.py:27 +#: assets/models/base.py:32 audits/models.py:102 +#: xpack/plugins/cloud/const.py:27 msgid "Failed" msgstr "失败" @@ -546,21 +567,21 @@ msgstr "可连接性" msgid "Date verified" msgstr "校验日期" -#: assets/models/base.py:175 xpack/plugins/change_auth_plan/models.py:72 +#: assets/models/base.py:178 xpack/plugins/change_auth_plan/models.py:72 #: xpack/plugins/change_auth_plan/models.py:197 #: xpack/plugins/change_auth_plan/models.py:292 msgid "SSH private key" msgstr "SSH密钥" -#: assets/models/base.py:176 xpack/plugins/change_auth_plan/models.py:75 +#: assets/models/base.py:179 xpack/plugins/change_auth_plan/models.py:75 #: xpack/plugins/change_auth_plan/models.py:193 #: xpack/plugins/change_auth_plan/models.py:288 msgid "SSH public key" msgstr "SSH公钥" -#: assets/models/base.py:179 assets/models/gathered_user.py:20 +#: assets/models/base.py:182 assets/models/gathered_user.py:20 #: common/db/models.py:73 common/mixins/models.py:51 ops/models/adhoc.py:39 -#: orgs/models.py:421 +#: orgs/models.py:436 msgid "Date updated" msgstr "更新日期" @@ -572,7 +593,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:570 +#: assets/models/cluster.py:22 users/models/user.py:575 msgid "Phone" msgstr "手机" @@ -598,7 +619,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:759 +#: users/models/user.py:769 msgid "System" msgstr "系统" @@ -606,7 +627,7 @@ msgstr "系统" msgid "Default Cluster" msgstr "默认Cluster" -#: assets/models/cmd_filter.py:33 assets/models/user.py:208 +#: assets/models/cmd_filter.py:33 assets/models/user.py:209 msgid "Command filter" msgstr "命令过滤器" @@ -647,13 +668,18 @@ msgstr "命令过滤规则" msgid "Command confirm" msgstr "命令复核" -#: assets/models/domain.py:62 +#: assets/models/domain.py:73 msgid "Gateway" msgstr "网关" -#: assets/models/domain.py:68 -msgid "Password should not contains special characters" -msgstr "密码不能包含特殊字符" +#: assets/models/domain.py:127 +#, python-brace-format +msgid "Unable to connect to port {port} on {ip}" +msgstr "无法连接到 {ip} 上的端口 {port}" + +#: assets/models/domain.py:130 +msgid "Authentication failed" +msgstr "认证失败" #: assets/models/gathered_user.py:16 msgid "Present" @@ -703,73 +729,73 @@ msgstr "全称" msgid "Parent key" msgstr "ssh私钥" -#: assets/models/node.py:559 assets/serializers/system_user.py:195 +#: assets/models/node.py:559 assets/serializers/system_user.py:199 #: 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:89 xpack/plugins/cloud/serializers.py:180 +#: xpack/plugins/cloud/models.py:93 xpack/plugins/cloud/serializers.py:206 msgid "Node" msgstr "节点" -#: assets/models/user.py:184 +#: assets/models/user.py:185 msgid "Automatic managed" msgstr "托管密码" -#: assets/models/user.py:185 +#: assets/models/user.py:186 msgid "Manually input" msgstr "手动输入" -#: assets/models/user.py:189 +#: assets/models/user.py:190 msgid "Common user" msgstr "普通用户" -#: assets/models/user.py:192 +#: assets/models/user.py:193 msgid "Username same with user" msgstr "用户名与用户相同" -#: assets/models/user.py:195 assets/serializers/domain.py:29 +#: assets/models/user.py:196 assets/serializers/domain.py:28 #: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:52 msgid "Assets" msgstr "资产" -#: assets/models/user.py:199 templates/_nav.html:17 -#: users/views/profile/password.py:43 users/views/profile/pubkey.py:37 +#: assets/models/user.py:200 templates/_nav.html:17 +#: users/views/profile/pubkey.py:37 msgid "Users" msgstr "用户管理" -#: assets/models/user.py:200 +#: assets/models/user.py:201 msgid "User groups" msgstr "用户组" -#: assets/models/user.py:204 +#: assets/models/user.py:205 msgid "Auto push" msgstr "自动推送" -#: assets/models/user.py:205 +#: assets/models/user.py:206 msgid "Sudo" msgstr "Sudo" -#: assets/models/user.py:206 +#: assets/models/user.py:207 msgid "Shell" msgstr "Shell" -#: assets/models/user.py:207 +#: assets/models/user.py:208 msgid "Login mode" msgstr "认证方式" -#: assets/models/user.py:209 +#: assets/models/user.py:210 msgid "SFTP Root" msgstr "SFTP根路径" -#: assets/models/user.py:210 authentication/models.py:95 +#: assets/models/user.py:211 authentication/models.py:95 msgid "Token" msgstr "" -#: assets/models/user.py:211 +#: assets/models/user.py:212 msgid "Home" msgstr "家目录" -#: assets/models/user.py:212 +#: assets/models/user.py:213 msgid "System groups" msgstr "用户组" @@ -790,7 +816,7 @@ msgstr "协议重复: {}" msgid "Domain name" msgstr "网域名称" -#: assets/serializers/asset.py:67 perms/serializers/asset/permission.py:49 +#: assets/serializers/asset.py:67 msgid "Nodes name" msgstr "节点名称" @@ -798,40 +824,33 @@ msgstr "节点名称" msgid "Hardware info" msgstr "硬件信息" -#: assets/serializers/asset.py:97 assets/serializers/system_user.py:213 +#: assets/serializers/asset.py:97 assets/serializers/system_user.py:217 #: orgs/mixins/serializers.py:26 msgid "Org name" msgstr "组织名称" #: assets/serializers/asset.py:98 msgid "Admin user display" -msgstr "管理用户名称" +msgstr "特权用户名称" #: assets/serializers/base.py:41 msgid "private key invalid" msgstr "密钥不合法" -#: assets/serializers/domain.py:13 -msgid "Assets count" -msgstr "资产数量" - -#: assets/serializers/domain.py:14 -msgid "Applications count" -msgstr "应用数量" - -#: assets/serializers/domain.py:15 -msgid "Gateways count" -msgstr "网关数量" - -#: assets/serializers/label.py:12 assets/serializers/system_user.py:48 -#: perms/serializers/asset/permission.py:74 +#: assets/serializers/domain.py:12 assets/serializers/label.py:12 +#: assets/serializers/system_user.py:52 +#: perms/serializers/asset/permission.py:72 msgid "Assets amount" msgstr "资产数量" -#: assets/serializers/label.py:13 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:24 -msgid "Category display" -msgstr "类别名称" +#: assets/serializers/domain.py:13 +#: perms/serializers/application/permission.py:43 +msgid "Applications amount" +msgstr "应用数量" + +#: assets/serializers/domain.py:14 +msgid "Gateways count" +msgstr "网关数量" #: assets/serializers/node.py:18 msgid "value" @@ -845,54 +864,56 @@ msgstr "不能包含: /" msgid "The same level node name cannot be the same" msgstr "同级别节点名字不能重复" -#: assets/serializers/system_user.py:25 audits/serializers.py:29 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:31 -#: tickets/serializers/ticket/ticket.py:19 -msgid "Type display" -msgstr "类型名称" - -#: assets/serializers/system_user.py:26 +#: assets/serializers/system_user.py:27 msgid "SSH key fingerprint" msgstr "密钥指纹" -#: assets/serializers/system_user.py:47 -#: perms/serializers/asset/permission.py:75 +#: assets/serializers/system_user.py:51 +#: perms/serializers/asset/permission.py:73 msgid "Nodes amount" msgstr "节点数量" -#: assets/serializers/system_user.py:51 +#: assets/serializers/system_user.py:53 assets/serializers/system_user.py:201 +msgid "Login mode display" +msgstr "认证方式名称" + +#: assets/serializers/system_user.py:55 msgid "Ad domain" msgstr "Ad 网域" -#: assets/serializers/system_user.py:91 +#: assets/serializers/system_user.py:95 msgid "Username same with user with protocol {} only allow 1" msgstr "用户名和用户相同的一种协议只允许存在一个" -#: assets/serializers/system_user.py:105 +#: assets/serializers/system_user.py:109 msgid "* Automatic login mode must fill in the username." msgstr "自动登录模式,必须填写用户名" -#: assets/serializers/system_user.py:119 +#: assets/serializers/system_user.py:123 msgid "Path should starts with /" msgstr "路径应该以 / 开头" -#: assets/serializers/system_user.py:144 +#: assets/serializers/system_user.py:148 msgid "Password or private key required" msgstr "密码或密钥密码需要一个" -#: assets/serializers/system_user.py:212 +#: assets/serializers/system_user.py:216 msgid "System user name" msgstr "系统用户名称" -#: assets/serializers/system_user.py:222 +#: assets/serializers/system_user.py:226 msgid "Asset hostname" msgstr "资产主机名" +#: assets/serializers/utils.py:9 +msgid "Password can not contains `{{` " +msgstr "密码不能包含 `{{` 字符" + #: assets/tasks/account_connectivity.py:30 msgid "The asset {} system platform {} does not support run Ansible tasks" msgstr "资产 {} 系统平台 {} 不支持运行 Ansible 任务" -#: assets/tasks/account_connectivity.py:106 +#: assets/tasks/account_connectivity.py:107 msgid "Test account connectivity: {}" msgstr "测试账号可连接性: {}" @@ -936,7 +957,7 @@ msgstr "更新资产硬件信息: {}" msgid "Update node asset hardware information: {}" msgstr "更新节点资产硬件信息: {}" -#: assets/tasks/gather_asset_users.py:108 +#: assets/tasks/gather_asset_users.py:111 msgid "Gather assets users" msgstr "收集资产上的用户" @@ -945,44 +966,44 @@ msgid "" "The task of self-checking is already running and cannot be started repeatedly" msgstr "自检程序已经在运行,不能重复启动" -#: assets/tasks/push_system_user.py:193 +#: assets/tasks/push_system_user.py:194 msgid "System user is dynamic: {}" msgstr "系统用户是动态的: {}" -#: assets/tasks/push_system_user.py:233 +#: assets/tasks/push_system_user.py:234 msgid "Start push system user for platform: [{}]" msgstr "推送系统用户到平台: [{}]" -#: assets/tasks/push_system_user.py:234 -#: assets/tasks/system_user_connectivity.py:105 +#: assets/tasks/push_system_user.py:235 +#: assets/tasks/system_user_connectivity.py:106 msgid "Hosts count: {}" msgstr "主机数量: {}" -#: assets/tasks/push_system_user.py:273 assets/tasks/push_system_user.py:299 +#: assets/tasks/push_system_user.py:274 assets/tasks/push_system_user.py:307 msgid "Push system users to assets: {}" msgstr "推送系统用户到入资产: {}" -#: assets/tasks/push_system_user.py:285 +#: assets/tasks/push_system_user.py:286 msgid "Push system users to asset: {}({}) => {}" msgstr "推送系统用户到入资产: {}({}) => {}" -#: assets/tasks/system_user_connectivity.py:55 +#: assets/tasks/system_user_connectivity.py:56 msgid "Dynamic system user not support test" msgstr "" -#: assets/tasks/system_user_connectivity.py:104 +#: assets/tasks/system_user_connectivity.py:105 msgid "Start test system user connectivity for platform: [{}]" msgstr "开始测试系统用户在该系统平台的可连接性: [{}]" -#: assets/tasks/system_user_connectivity.py:117 +#: assets/tasks/system_user_connectivity.py:118 msgid "Test system user connectivity: {}" msgstr "测试系统用户可连接性: {}" -#: assets/tasks/system_user_connectivity.py:128 +#: assets/tasks/system_user_connectivity.py:129 msgid "Test system user connectivity: {} => {}" msgstr "测试系统用户可连接性: {} => {}" -#: assets/tasks/system_user_connectivity.py:141 +#: assets/tasks/system_user_connectivity.py:148 msgid "Test system user connectivity period: {}" msgstr "定期测试系统用户可连接性: {}" @@ -1033,7 +1054,7 @@ msgstr "创建目录" msgid "Symlink" msgstr "建立软链接" -#: audits/models.py:37 audits/models.py:60 audits/models.py:71 +#: audits/models.py:37 audits/models.py:60 audits/models.py:76 #: terminal/models/session.py:45 msgid "Remote addr" msgstr "远端地址" @@ -1046,7 +1067,7 @@ msgstr "操作" msgid "Filename" msgstr "文件名" -#: audits/models.py:42 audits/models.py:96 +#: audits/models.py:42 audits/models.py:101 msgid "Success" msgstr "成功" @@ -1082,67 +1103,67 @@ msgstr "资源类型" msgid "Resource" msgstr "资源" -#: audits/models.py:61 audits/models.py:72 +#: audits/models.py:61 audits/models.py:77 msgid "Datetime" msgstr "日期" -#: audits/models.py:70 +#: audits/models.py:75 msgid "Change by" msgstr "修改者" -#: audits/models.py:90 +#: audits/models.py:95 msgid "Disabled" msgstr "禁用" -#: audits/models.py:91 settings/models.py:33 +#: audits/models.py:96 settings/models.py:33 msgid "Enabled" msgstr "启用" -#: audits/models.py:92 +#: audits/models.py:97 msgid "-" msgstr "" -#: audits/models.py:101 +#: audits/models.py:106 msgid "Login type" msgstr "登录方式" -#: audits/models.py:102 +#: audits/models.py:107 #: tickets/serializers/ticket/meta/ticket_type/login_confirm.py:14 msgid "Login ip" msgstr "登录IP" -#: audits/models.py:103 +#: audits/models.py:108 #: tickets/serializers/ticket/meta/ticket_type/login_confirm.py:17 msgid "Login city" msgstr "登录城市" -#: audits/models.py:104 audits/serializers.py:44 +#: audits/models.py:109 audits/serializers.py:44 msgid "User agent" msgstr "用户代理" -#: audits/models.py:105 +#: audits/models.py:110 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:64 users/models/user.py:573 +#: users/forms/profile.py:64 users/models/user.py:578 #: users/serializers/profile.py:102 msgid "MFA" msgstr "多因子认证" -#: audits/models.py:106 xpack/plugins/change_auth_plan/models.py:303 -#: xpack/plugins/cloud/models.py:161 +#: audits/models.py:111 xpack/plugins/change_auth_plan/models.py:303 +#: xpack/plugins/cloud/models.py:171 msgid "Reason" msgstr "原因" -#: audits/models.py:107 tickets/models/ticket.py:47 -#: xpack/plugins/cloud/models.py:157 xpack/plugins/cloud/models.py:206 +#: audits/models.py:112 tickets/models/ticket.py:47 +#: xpack/plugins/cloud/models.py:167 xpack/plugins/cloud/models.py:216 msgid "Status" msgstr "状态" -#: audits/models.py:108 +#: audits/models.py:113 msgid "Date login" msgstr "登录日期" -#: audits/models.py:109 +#: audits/models.py:114 msgid "Authentication backend" msgstr "认证方式" @@ -1168,7 +1189,7 @@ msgid "Hosts display" msgstr "主机名称" #: audits/serializers.py:89 ops/models/command.py:26 -#: xpack/plugins/cloud/models.py:155 +#: xpack/plugins/cloud/models.py:165 msgid "Result" msgstr "结果" @@ -1182,37 +1203,206 @@ msgstr "运行用户" #: audits/serializers.py:94 msgid "Run as display" -msgstr "运行用户" +msgstr "运行用户名称" #: audits/serializers.py:95 msgid "User display" -msgstr "用户" +msgstr "用户名称" -#: audits/signals_handler.py:57 +#: audits/signals_handler.py:62 msgid "SSH Key" msgstr "SSH 密钥" -#: audits/signals_handler.py:59 +#: audits/signals_handler.py:64 msgid "SSO" msgstr "" -#: audits/signals_handler.py:60 +#: audits/signals_handler.py:65 msgid "Auth Token" msgstr "认证令牌" -#: audits/signals_handler.py:61 +#: audits/signals_handler.py:66 #: authentication/templates/authentication/login.html:210 -#: notifications/backends/__init__.py:12 +#: notifications/backends/__init__.py:13 msgid "WeCom" msgstr "企业微信" -#: audits/signals_handler.py:62 +#: audits/signals_handler.py:67 #: authentication/templates/authentication/login.html:215 -#: notifications/backends/__init__.py:13 +#: notifications/backends/__init__.py:14 msgid "DingTalk" msgstr "钉钉" -#: authentication/api/connection_token.py:258 +#: audits/signals_handler.py:100 +msgid "User and Organization" +msgstr "用户与组织" + +#: audits/signals_handler.py:101 +#, python-brace-format +msgid "{User} *JOINED* {Organization}" +msgstr "{User} *加入了* {Organization}" + +#: audits/signals_handler.py:102 +#, python-brace-format +msgid "{User} *LEFT* {Organization}" +msgstr "{User} *离开了* {Organization}" + +#: audits/signals_handler.py:105 +msgid "User and Group" +msgstr "用户与用户组" + +#: audits/signals_handler.py:106 +#, python-brace-format +msgid "{User} *JOINED* {UserGroup}" +msgstr "{User} *加入了* {UserGroup}" + +#: audits/signals_handler.py:107 +#, python-brace-format +msgid "{User} *LEFT* {UserGroup}" +msgstr "{User} *离开了* {UserGroup}" + +#: audits/signals_handler.py:110 +msgid "Node and Asset" +msgstr "节点与资产" + +#: audits/signals_handler.py:111 +#, python-brace-format +msgid "{Node} *ADD* {Asset}" +msgstr "{Node} *添加了* {Asset}" + +#: audits/signals_handler.py:112 +#, python-brace-format +msgid "{Node} *REMOVE* {Asset}" +msgstr "{Node} *移除了* {Asset}" + +#: audits/signals_handler.py:115 +msgid "User asset permissions" +msgstr "用户资产授权" + +#: audits/signals_handler.py:116 +#, python-brace-format +msgid "{AssetPermission} *ADD* {User}" +msgstr "{AssetPermission} *添加了* {User}" + +#: audits/signals_handler.py:117 +#, python-brace-format +msgid "{AssetPermission} *REMOVE* {User}" +msgstr "{AssetPermission} *移除了* {User}" + +#: audits/signals_handler.py:120 +msgid "User group asset permissions" +msgstr "用户组资产授权" + +#: audits/signals_handler.py:121 +#, python-brace-format +msgid "{AssetPermission} *ADD* {UserGroup}" +msgstr "{AssetPermission} *添加了* {UserGroup}" + +#: audits/signals_handler.py:122 +#, python-brace-format +msgid "{AssetPermission} *REMOVE* {UserGroup}" +msgstr "{AssetPermission} *移除了* {UserGroup}" + +#: audits/signals_handler.py:125 perms/models/asset_permission.py:106 +#: templates/_nav.html:78 users/templates/users/_user_detail_nav_header.html:31 +msgid "Asset permission" +msgstr "资产授权" + +#: audits/signals_handler.py:126 +#, python-brace-format +msgid "{AssetPermission} *ADD* {Asset}" +msgstr "{AssetPermission} *添加了* {Asset}" + +#: audits/signals_handler.py:127 +#, python-brace-format +msgid "{AssetPermission} *REMOVE* {Asset}" +msgstr "{AssetPermission} *移除了* {Asset}" + +#: audits/signals_handler.py:130 +msgid "Node permission" +msgstr "节点授权" + +#: audits/signals_handler.py:131 +#, python-brace-format +msgid "{AssetPermission} *ADD* {Node}" +msgstr "{AssetPermission} *添加了* {Node}" + +#: audits/signals_handler.py:132 +#, python-brace-format +msgid "{AssetPermission} *REMOVE* {Node}" +msgstr "{AssetPermission} *移除了* {Node}" + +#: audits/signals_handler.py:135 +msgid "Asset permission and SystemUser" +msgstr "资产授权与系统用户" + +#: audits/signals_handler.py:136 +#, python-brace-format +msgid "{AssetPermission} *ADD* {SystemUser}" +msgstr "{AssetPermission} *添加了* {SystemUser}" + +#: audits/signals_handler.py:137 +#, python-brace-format +msgid "{AssetPermission} *REMOVE* {SystemUser}" +msgstr "{AssetPermission} *移除了* {SystemUser}" + +#: audits/signals_handler.py:140 +msgid "User application permissions" +msgstr "用户应用授权" + +#: audits/signals_handler.py:141 +#, python-brace-format +msgid "{ApplicationPermission} *ADD* {User}" +msgstr "{ApplicationPermission} *添加了* {User}" + +#: audits/signals_handler.py:142 +#, python-brace-format +msgid "{ApplicationPermission} *REMOVE* {User}" +msgstr "{ApplicationPermission} *移除了* {User}" + +#: audits/signals_handler.py:145 +msgid "User group application permissions" +msgstr "用户组应用授权" + +#: audits/signals_handler.py:146 +#, python-brace-format +msgid "{ApplicationPermission} *ADD* {UserGroup}" +msgstr "{ApplicationPermission} *添加了* {UserGroup}" + +#: audits/signals_handler.py:147 +#, python-brace-format +msgid "{ApplicationPermission} *REMOVE* {UserGroup}" +msgstr "{ApplicationPermission} *移除了* {UserGroup}" + +#: audits/signals_handler.py:150 perms/models/application_permission.py:36 +msgid "Application permission" +msgstr "应用管理" + +#: audits/signals_handler.py:151 +#, python-brace-format +msgid "{ApplicationPermission} *ADD* {Application}" +msgstr "{ApplicationPermission} *添加了* {Application}" + +#: audits/signals_handler.py:152 +#, python-brace-format +msgid "{ApplicationPermission} *REMOVE* {Application}" +msgstr "{ApplicationPermission} *移除了* {Application}" + +#: audits/signals_handler.py:155 +msgid "Application permission and SystemUser" +msgstr "应用授权与系统用户" + +#: audits/signals_handler.py:156 +#, python-brace-format +msgid "{ApplicationPermission} *ADD* {SystemUser}" +msgstr "{ApplicationPermission} *添加了* {SystemUser}" + +#: audits/signals_handler.py:157 +#, python-brace-format +msgid "{ApplicationPermission} *REMOVE* {SystemUser}" +msgstr "{ApplicationPermission} *移除了* {SystemUser}" + +#: authentication/api/connection_token.py:222 msgid "Invalid token" msgstr "无效的令牌" @@ -1388,7 +1578,7 @@ msgstr "登录完成前,请先修改密码" msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" -#: authentication/errors.py:320 +#: authentication/errors.py:325 msgid "Your password is invalid" msgstr "您的密码无效" @@ -1444,7 +1634,8 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: users/models/user.py:463 users/serializers/profile.py:99 +#: settings/serializers/settings.py:149 users/models/user.py:463 +#: users/serializers/profile.py:99 #: users/templates/users/user_verify_mfa.html:32 msgid "Disable" msgstr "禁用" @@ -1515,6 +1706,11 @@ msgstr "OpenID" #: authentication/templates/authentication/login.html:205 msgid "CAS" +msgstr "CAS" + +#: authentication/templates/authentication/login.html:220 +#: notifications/backends/__init__.py:16 +msgid "FeiShu" msgstr "" #: authentication/templates/authentication/login_otp.html:17 @@ -1562,7 +1758,8 @@ msgstr "钉钉错误,请联系系统管理员" msgid "DingTalk Error" msgstr "钉钉错误" -#: authentication/views/dingtalk.py:56 authentication/views/wecom.py:56 +#: authentication/views/dingtalk.py:56 authentication/views/feishu.py:55 +#: authentication/views/wecom.py:56 msgid "You've been hacked" msgstr "你被攻击了" @@ -1570,7 +1767,8 @@ msgstr "你被攻击了" msgid "DingTalk is already bound" msgstr "钉钉已经绑定" -#: authentication/views/dingtalk.py:105 authentication/views/wecom.py:104 +#: authentication/views/dingtalk.py:105 authentication/views/feishu.py:102 +#: authentication/views/wecom.py:104 msgid "Please verify your password first" msgstr "请检查密码" @@ -1599,7 +1797,8 @@ msgstr "从钉钉获取用户失败" msgid "DingTalk is not bound" msgstr "钉钉没有绑定" -#: authentication/views/dingtalk.py:218 authentication/views/wecom.py:216 +#: authentication/views/dingtalk.py:218 authentication/views/feishu.py:208 +#: authentication/views/wecom.py:216 msgid "Please login with a password and then bind the WeCom" msgstr "请使用密码登录,然后绑定企业微信" @@ -1607,19 +1806,56 @@ msgstr "请使用密码登录,然后绑定企业微信" msgid "Binding DingTalk failed" msgstr "绑定钉钉失败" -#: authentication/views/login.py:60 +#: authentication/views/feishu.py:40 +msgid "FeiShu Error, Please contact your system administrator" +msgstr "飞书错误,请联系系统管理员" + +#: authentication/views/feishu.py:43 +msgid "FeiShu Error" +msgstr "飞书错误" + +#: authentication/views/feishu.py:89 +msgid "FeiShu is already bound" +msgstr "飞书已经绑定" + +#: authentication/views/feishu.py:136 +msgid "FeiShu query user failed" +msgstr "飞书查询用户失败" + +#: authentication/views/feishu.py:145 +msgid "The FeiShu is already bound to another user" +msgstr "该飞书已经绑定其他用户" + +#: authentication/views/feishu.py:150 authentication/views/feishu.py:232 +#: authentication/views/feishu.py:233 +msgid "Binding FeiShu successfully" +msgstr "绑定 飞书 成功" + +#: authentication/views/feishu.py:201 +msgid "Failed to get user from FeiShu" +msgstr "从飞书获取用户失败" + +#: authentication/views/feishu.py:207 +msgid "FeiShu is not bound" +msgstr "没有绑定飞书" + +#: authentication/views/feishu.py:250 authentication/views/feishu.py:251 +msgid "Binding FeiShu failed" +msgstr "绑定飞书失败" + +#: authentication/views/login.py:78 msgid "Redirecting" msgstr "跳转中" -#: authentication/views/login.py:61 +#: authentication/views/login.py:79 msgid "Redirecting to {} authentication" msgstr "正在跳转到 {} 认证" -#: authentication/views/login.py:85 +#: authentication/views/login.py:105 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:203 +#: authentication/views/login.py:224 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1627,15 +1863,15 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:208 +#: authentication/views/login.py:229 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:240 +#: authentication/views/login.py:261 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:241 +#: authentication/views/login.py:262 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" @@ -1717,7 +1953,7 @@ msgstr "您的请求超时了" #: common/exceptions.py:35 msgid "M2M reverse not allowed" -msgstr "" +msgstr "多对多反向是不被允许的" #: common/exceptions.py:41 msgid "Is referenced by other objects and cannot be deleted" @@ -1725,55 +1961,55 @@ msgstr "被其他对象关联,不能删除" #: common/exceptions.py:47 msgid "This action require verify your MFA" -msgstr "" +msgstr "这个操作需要验证 MFA" #: common/fields/model.py:80 msgid "Marshal dict data to char field" -msgstr "" +msgstr "编码 dict 为 char" #: common/fields/model.py:84 msgid "Marshal dict data to text field" -msgstr "" +msgstr "编码 dict 为 text" #: common/fields/model.py:96 msgid "Marshal list data to char field" -msgstr "" +msgstr "编码 list 为 char" #: common/fields/model.py:100 msgid "Marshal list data to text field" -msgstr "" +msgstr "编码 list 为 text" #: common/fields/model.py:104 msgid "Marshal data to char field" -msgstr "" +msgstr "编码数据为 char" #: common/fields/model.py:108 msgid "Marshal data to text field" -msgstr "" +msgstr "编码数据为 text" #: common/fields/model.py:150 msgid "Encrypt field using Secret Key" -msgstr "" +msgstr "加密的字段" #: common/message/backends/exceptions.py:23 msgid "Network error, please contact system administrator" msgstr "网络错误,请联系系统管理员" -#: common/message/backends/wecom/__init__.py:19 +#: common/message/backends/wecom/__init__.py:15 msgid "WeCom error, please contact system administrator" msgstr "企业微信错误,请联系系统管理员" -#: common/mixins/api.py:57 +#: common/mixins/api.py:58 msgid "Request file format may be wrong" msgstr "上传的文件格式错误 或 其它类型资源的文件" #: common/mixins/models.py:33 msgid "is discard" -msgstr "" +msgstr "忽略的" #: common/mixins/models.py:34 msgid "discard time" -msgstr "" +msgstr "忽略时间" #: common/utils/ipip/utils.py:15 msgid "Invalid ip" @@ -1791,13 +2027,13 @@ msgstr "字段必须唯一" msgid "Should not contains special characters" msgstr "不能包含特殊字符" -#: jumpserver/context_processor.py:19 +#: jumpserver/context_processor.py:20 msgid "JumpServer Open Source Bastion Host" msgstr "JumpServer 开源堡垒机" #: jumpserver/views/celery_flower.py:23 -msgid "

Flow service unavailable, check it

" -msgstr "" +msgid "

Flower service unavailable, check it

" +msgstr "Flower 服务不可用,请检查" #: jumpserver/views/other.py:25 msgid "" @@ -1822,12 +2058,12 @@ msgstr "" "div>
如果你看到了这个页面,证明你访问的不是nginx监听的端口,祝你好运" -#: notifications/backends/__init__.py:11 users/forms/profile.py:101 -#: users/models/user.py:553 +#: notifications/backends/__init__.py:12 users/forms/profile.py:101 +#: users/models/user.py:558 msgid "Email" msgstr "邮件" -#: notifications/backends/__init__.py:14 +#: notifications/backends/__init__.py:15 msgid "Site message" msgstr "站内信" @@ -1839,7 +2075,7 @@ msgstr "等待任务开始" msgid "Not has host {} permission" msgstr "没有该主机 {} 权限" -#: ops/apps.py:9 ops/notifications.py:13 +#: ops/apps.py:9 ops/notifications.py:14 msgid "Operations" msgstr "运维" @@ -1852,7 +2088,7 @@ msgid "Regularly perform" msgstr "定期执行" #: ops/mixin.py:106 ops/mixin.py:147 -#: xpack/plugins/change_auth_plan/serializers.py:51 +#: xpack/plugins/change_auth_plan/serializers.py:55 msgid "Periodic perform" msgstr "定时执行" @@ -1917,7 +2153,7 @@ msgstr "创建者" #: ops/models/adhoc.py:240 msgid "Task display" -msgstr "任务展示" +msgstr "任务名称" #: ops/models/adhoc.py:242 msgid "Host amount" @@ -1966,13 +2202,26 @@ msgstr "命令 `{}` 不允许被执行 ......." msgid "Task end" msgstr "任务结束" -#: ops/notifications.py:14 +#: ops/notifications.py:15 msgid "Server performance" msgstr "监控告警" -#: ops/notifications.py:21 -msgid "Disk used more than 80%: {} => {}" -msgstr "磁盘使用率超过 80%: {} => {}" +#: ops/notifications.py:36 +#, python-brace-format +msgid "[Alive] The terminal is offline: {name}" +msgstr "" + +#: ops/notifications.py:42 +msgid "[Disk] Disk used more than {max_threshold}%: => {value} ({name})" +msgstr "[Disk] 硬盘使用率超过 {max_threshold}%: => {value} ({name})" + +#: ops/notifications.py:49 +msgid "[Memory] Memory used more than {max_threshold}%: => {value} ({name})" +msgstr "[Memory] 内存使用率超过 {max_threshold}%: => {value} ({name})" + +#: ops/notifications.py:56 +msgid "[CPU] CPU load more than {max_threshold}: => {value} ({name})" +msgstr "[CPU] CPU 使用率超过 {max_threshold}: => {value} ({name})" #: ops/tasks.py:71 msgid "Clean task history period" @@ -1998,25 +2247,25 @@ msgstr "当前组织 ({}) 不能被删除" msgid "The organization have resource ({}) cannot be deleted" msgstr "组织存在资源 ({}) 不能被删除" -#: orgs/mixins/models.py:45 orgs/mixins/serializers.py:25 orgs/models.py:36 -#: orgs/models.py:417 orgs/serializers.py:106 +#: orgs/mixins/models.py:45 orgs/mixins/serializers.py:25 orgs/models.py:37 +#: orgs/models.py:432 orgs/serializers.py:106 #: tickets/serializers/ticket/ticket.py:83 msgid "Organization" msgstr "组织" -#: orgs/models.py:16 +#: orgs/models.py:17 msgid "Organization administrator" msgstr "组织管理员" -#: orgs/models.py:17 +#: orgs/models.py:18 msgid "Organization auditor" msgstr "组织审计员" -#: orgs/models.py:30 +#: orgs/models.py:31 msgid "GLOBAL" msgstr "全局组织" -#: orgs/models.py:419 users/models/user.py:561 +#: orgs/models.py:434 users/models/user.py:566 #: users/templates/users/_select_user_modal.html:15 msgid "Role" msgstr "角色" @@ -2033,11 +2282,7 @@ msgstr "该授权暂时不能撤销" msgid "Application" msgstr "应用程序" -#: perms/models/application_permission.py:36 -msgid "Application permission" -msgstr "应用管理" - -#: perms/models/asset_permission.py:37 settings/serializers/settings.py:117 +#: perms/models/asset_permission.py:37 settings/serializers/settings.py:118 msgid "All" msgstr "全部" @@ -2070,15 +2315,12 @@ msgid "Clipboard copy paste" msgstr "剪贴板复制粘贴" #: perms/models/asset_permission.py:102 -#: perms/serializers/asset/permission.py:71 +#: perms/serializers/application/permission.py:39 +#: perms/serializers/asset/permission.py:41 +#: perms/serializers/asset/permission.py:69 msgid "Actions" msgstr "动作" -#: perms/models/asset_permission.py:106 templates/_nav.html:78 -#: users/templates/users/_user_detail_nav_header.html:31 -msgid "Asset permission" -msgstr "资产授权" - #: perms/models/asset_permission.py:209 msgid "Ungrouped" msgstr "未分组" @@ -2088,7 +2330,7 @@ msgid "Favorite" msgstr "收藏夹" #: perms/models/base.py:51 templates/_nav.html:21 users/models/group.py:31 -#: users/models/user.py:557 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:562 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 @@ -2101,54 +2343,66 @@ msgstr "用户组" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:77 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:43 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:81 -#: users/models/user.py:589 +#: users/models/user.py:594 msgid "Date expired" msgstr "失效日期" -#: perms/serializers/application/permission.py:53 +#: perms/serializers/application/permission.py:18 +#: perms/serializers/application/permission.py:38 +#: perms/serializers/asset/permission.py:42 +#: perms/serializers/asset/permission.py:68 users/serializers/user.py:80 +msgid "Is valid" +msgstr "账户是否有效" + +#: perms/serializers/application/permission.py:19 +#: perms/serializers/application/permission.py:37 +#: perms/serializers/asset/permission.py:43 +#: perms/serializers/asset/permission.py:67 users/serializers/user.py:33 +#: users/serializers/user.py:81 +msgid "Is expired" +msgstr "是否过期" + +#: perms/serializers/application/permission.py:40 +#: perms/serializers/asset/permission.py:70 users/serializers/group.py:34 +msgid "Users amount" +msgstr "用户数量" + +#: perms/serializers/application/permission.py:41 +#: perms/serializers/asset/permission.py:71 +msgid "User groups amount" +msgstr "用户组数量" + +#: perms/serializers/application/permission.py:42 +#: perms/serializers/asset/permission.py:74 +msgid "System users amount" +msgstr "系统用户数量" + +#: perms/serializers/application/permission.py:66 msgid "" "The application list contains applications that are different from the " "permission type. ({})" msgstr "应用列表中包含与授权类型不同的应用。({})" +#: perms/serializers/asset/permission.py:44 +msgid "Users display" +msgstr "用户名称" + #: perms/serializers/asset/permission.py:45 -#: perms/serializers/asset/permission.py:69 users/serializers/user.py:33 -#: users/serializers/user.py:81 -msgid "Is expired" -msgstr "是否过期" +msgid "User groups display" +msgstr "用户名称" #: perms/serializers/asset/permission.py:46 -msgid "Users name" -msgstr "用户名" - -#: perms/serializers/asset/permission.py:47 -msgid "User groups name" -msgstr "用户组名称" - -#: perms/serializers/asset/permission.py:48 -msgid "Assets name" +msgid "Assets display" msgstr "资产名称" -#: perms/serializers/asset/permission.py:50 -msgid "System users name" +#: perms/serializers/asset/permission.py:47 +msgid "Nodes display" +msgstr "节点名称" + +#: perms/serializers/asset/permission.py:48 +msgid "System users display" msgstr "系统用户名称" -#: perms/serializers/asset/permission.py:70 users/serializers/user.py:80 -msgid "Is valid" -msgstr "账户是否有效" - -#: perms/serializers/asset/permission.py:72 users/serializers/group.py:34 -msgid "Users amount" -msgstr "用户数量" - -#: perms/serializers/asset/permission.py:73 -msgid "User groups amount" -msgstr "用户组数量" - -#: perms/serializers/asset/permission.py:76 -msgid "System users amount" -msgstr "系统用户数量" - #: settings/api/common.py:25 msgid "Test mail sent to {}, please check" msgstr "邮件已经发送{}, 请检查" @@ -2158,15 +2412,16 @@ msgstr "邮件已经发送{}, 请检查" msgid "Welcome to the JumpServer open source Bastion Host" msgstr "欢迎使用JumpServer开源堡垒机" -#: settings/api/dingtalk.py:36 settings/api/wecom.py:36 +#: settings/api/dingtalk.py:36 settings/api/feishu.py:35 +#: settings/api/wecom.py:36 msgid "Test success" msgstr "测试成功" -#: settings/api/ldap.py:197 +#: settings/api/ldap.py:194 msgid "Get ldap users is None" msgstr "获取 LDAP 用户为 None" -#: settings/api/ldap.py:206 +#: settings/api/ldap.py:203 msgid "Imported {} users successfully (Organization: {})" msgstr "成功导入 {} 个用户 ( 组织: {} )" @@ -2174,27 +2429,27 @@ msgstr "成功导入 {} 个用户 ( 组织: {} )" msgid "Setting" msgstr "设置" -#: settings/serializers/settings.py:15 +#: settings/serializers/settings.py:16 msgid "Site url" msgstr "当前站点URL" -#: settings/serializers/settings.py:16 +#: settings/serializers/settings.py:17 msgid "eg: http://dev.jumpserver.org:8080" msgstr "如: http://dev.jumpserver.org:8080" -#: settings/serializers/settings.py:20 +#: settings/serializers/settings.py:21 msgid "User guide url" msgstr "用户向导URL" -#: settings/serializers/settings.py:21 +#: settings/serializers/settings.py:22 msgid "User first login update profile done redirect to it" msgstr "用户第一次登录,修改profile后重定向到地址, 可以是 wiki 或 其他说明文档" -#: settings/serializers/settings.py:24 +#: settings/serializers/settings.py:25 msgid "Forgot password url" msgstr "忘记密码URL" -#: settings/serializers/settings.py:25 +#: settings/serializers/settings.py:26 msgid "" "The forgot password url on login page, If you use ldap or cas external " "authentication, you can set it" @@ -2202,138 +2457,138 @@ msgstr "" "登录页面忘记密码URL, 如果使用了 LDAP, OPENID 等外部认证系统,可以自定义用户重" "置密码访问的地址" -#: settings/serializers/settings.py:29 +#: settings/serializers/settings.py:30 msgid "Global organization name" msgstr "全局组织名" -#: settings/serializers/settings.py:30 +#: settings/serializers/settings.py:31 msgid "The name of global organization to display" msgstr "全局组织的显示名称,默认为 全局组织" -#: settings/serializers/settings.py:37 +#: settings/serializers/settings.py:38 msgid "SMTP host" msgstr "SMTP 主机" -#: settings/serializers/settings.py:38 +#: settings/serializers/settings.py:39 msgid "SMTP port" msgstr "SMTP 端口" -#: settings/serializers/settings.py:39 +#: settings/serializers/settings.py:40 msgid "SMTP account" msgstr "SMTP 账号" -#: settings/serializers/settings.py:41 +#: settings/serializers/settings.py:42 msgid "SMTP password" msgstr "SMTP 密码" -#: settings/serializers/settings.py:42 +#: settings/serializers/settings.py:43 msgid "Tips: Some provider use token except password" msgstr "提示:一些邮件提供商需要输入的是授权码" -#: settings/serializers/settings.py:45 +#: settings/serializers/settings.py:46 msgid "Send user" msgstr "发件人" -#: settings/serializers/settings.py:46 +#: settings/serializers/settings.py:47 msgid "Tips: Send mail account, default SMTP account as the send account" msgstr "提示:发送邮件账号,默认使用 SMTP 账号作为发送账号" -#: settings/serializers/settings.py:49 +#: settings/serializers/settings.py:50 msgid "Test recipient" msgstr "测试收件人" -#: settings/serializers/settings.py:50 +#: settings/serializers/settings.py:51 msgid "Tips: Used only as a test mail recipient" msgstr "提示:仅用来作为测试邮件收件人" -#: settings/serializers/settings.py:53 +#: settings/serializers/settings.py:54 msgid "Use SSL" msgstr "使用 SSL" -#: settings/serializers/settings.py:54 +#: settings/serializers/settings.py:55 msgid "If SMTP port is 465, may be select" msgstr "如果SMTP端口是465,通常需要启用 SSL" -#: settings/serializers/settings.py:57 +#: settings/serializers/settings.py:58 msgid "Use TLS" msgstr "使用 TLS" -#: settings/serializers/settings.py:58 +#: settings/serializers/settings.py:59 msgid "If SMTP port is 587, may be select" msgstr "如果SMTP端口是587,通常需要启用 TLS" -#: settings/serializers/settings.py:61 +#: settings/serializers/settings.py:62 msgid "Subject prefix" msgstr "主题前缀" -#: settings/serializers/settings.py:68 +#: settings/serializers/settings.py:69 msgid "Create user email subject" msgstr "邮件主题" -#: settings/serializers/settings.py:69 +#: settings/serializers/settings.py:70 msgid "" "Tips: When creating a user, send the subject of the email (eg:Create account " "successfully)" msgstr "提示: 创建用户时,发送设置密码邮件的主题 (例如: 创建用户成功)" -#: settings/serializers/settings.py:73 +#: settings/serializers/settings.py:74 msgid "Create user honorific" msgstr "邮件的敬语" -#: settings/serializers/settings.py:74 +#: settings/serializers/settings.py:75 msgid "Tips: When creating a user, send the honorific of the email (eg:Hello)" msgstr "提示: 创建用户时,发送设置密码邮件的敬语 (例如: 您好)" -#: settings/serializers/settings.py:78 +#: settings/serializers/settings.py:79 msgid "Create user email content" msgstr "邮件的内容" -#: settings/serializers/settings.py:79 +#: settings/serializers/settings.py:80 msgid "Tips:When creating a user, send the content of the email" msgstr "提示: 创建用户时,发送设置密码邮件的内容" -#: settings/serializers/settings.py:82 +#: settings/serializers/settings.py:83 msgid "Signature" msgstr "署名" -#: settings/serializers/settings.py:83 +#: settings/serializers/settings.py:84 msgid "Tips: Email signature (eg:jumpserver)" msgstr "邮件署名 (如:jumpserver)" -#: settings/serializers/settings.py:91 +#: settings/serializers/settings.py:92 msgid "LDAP server" msgstr "LDAP 地址" -#: settings/serializers/settings.py:91 +#: settings/serializers/settings.py:92 msgid "eg: ldap://localhost:389" -msgstr "" +msgstr "如: ldap://localhost:389" -#: settings/serializers/settings.py:93 +#: settings/serializers/settings.py:94 msgid "Bind DN" msgstr "绑定 DN" -#: settings/serializers/settings.py:96 +#: settings/serializers/settings.py:97 msgid "User OU" msgstr "用户 OU" -#: settings/serializers/settings.py:97 +#: settings/serializers/settings.py:98 msgid "Use | split multi OUs" msgstr "多个 OU 使用 | 分割" -#: settings/serializers/settings.py:100 +#: settings/serializers/settings.py:101 msgid "User search filter" msgstr "用户过滤器" -#: settings/serializers/settings.py:101 +#: settings/serializers/settings.py:102 #, python-format msgid "Choice may be (cn|uid|sAMAccountName)=%(user)s)" msgstr "可能的选项是(cn或uid或sAMAccountName=%(user)s)" -#: settings/serializers/settings.py:104 +#: settings/serializers/settings.py:105 msgid "User attr map" msgstr "用户属性映射" -#: settings/serializers/settings.py:105 +#: settings/serializers/settings.py:106 msgid "" "User attr map present how to map LDAP user attr to jumpserver, username,name," "email is jumpserver attr" @@ -2341,23 +2596,23 @@ msgstr "" "用户属性映射代表怎样将LDAP中用户属性映射到jumpserver用户上,username, name," "email 是jumpserver的用户需要属性" -#: settings/serializers/settings.py:107 +#: settings/serializers/settings.py:108 msgid "Enable LDAP auth" msgstr "启用 LDAP 认证" -#: settings/serializers/settings.py:118 +#: settings/serializers/settings.py:119 msgid "Auto" msgstr "自动" -#: settings/serializers/settings.py:124 +#: settings/serializers/settings.py:125 msgid "Password auth" msgstr "密码认证" -#: settings/serializers/settings.py:126 +#: settings/serializers/settings.py:127 msgid "Public key auth" msgstr "密钥认证" -#: settings/serializers/settings.py:127 +#: settings/serializers/settings.py:128 msgid "" "Tips: If use other auth method, like AD/LDAP, you should disable this to " "avoid being able to log in after deleting" @@ -2365,19 +2620,19 @@ msgstr "" "提示:如果你使用其它认证方式,如 AD/LDAP,你应该禁用此项,以避免第三方系统删" "除后,还可以登录" -#: settings/serializers/settings.py:130 +#: settings/serializers/settings.py:131 msgid "List sort by" msgstr "资产列表排序" -#: settings/serializers/settings.py:131 +#: settings/serializers/settings.py:132 msgid "List page size" msgstr "资产列表每页数量" -#: settings/serializers/settings.py:133 +#: settings/serializers/settings.py:134 msgid "Session keep duration" msgstr "会话日志保存时间" -#: settings/serializers/settings.py:134 +#: settings/serializers/settings.py:135 msgid "" "Units: days, Session, record, command will be delete if more than duration, " "only in database" @@ -2385,72 +2640,76 @@ msgstr "" "单位:天。 会话、录像、命令记录超过该时长将会被删除(仅影响数据库存储, oss等不" "受影响)" -#: settings/serializers/settings.py:136 +#: settings/serializers/settings.py:137 msgid "Telnet login regex" msgstr "Telnet 成功正则表达式" -#: settings/serializers/settings.py:138 +#: settings/serializers/settings.py:139 msgid "RDP address" msgstr "RDP 地址" -#: settings/serializers/settings.py:141 +#: settings/serializers/settings.py:142 msgid "RDP visit address, eg: dev.jumpserver.org:3389" msgstr "RDP 访问地址, 如: dev.jumpserver.org:3389" -#: settings/serializers/settings.py:147 +#: settings/serializers/settings.py:150 +msgid "All users" +msgstr "所有用户" + +#: settings/serializers/settings.py:151 +msgid "Only admin users" +msgstr "仅管理员" + +#: settings/serializers/settings.py:153 msgid "Global MFA auth" msgstr "全局启用 MFA 认证" -#: settings/serializers/settings.py:148 -msgid "All user enable MFA" -msgstr "强制所有用户启用多因子认证" - -#: settings/serializers/settings.py:151 +#: settings/serializers/settings.py:156 msgid "Batch command execution" msgstr "批量命令执行" -#: settings/serializers/settings.py:152 +#: settings/serializers/settings.py:157 msgid "Allow user run batch command or not using ansible" msgstr "是否允许用户使用 ansible 执行批量命令" -#: settings/serializers/settings.py:155 +#: settings/serializers/settings.py:160 msgid "Enable terminal register" msgstr "终端注册" -#: settings/serializers/settings.py:156 +#: settings/serializers/settings.py:161 msgid "" "Allow terminal register, after all terminal setup, you should disable this " "for security" msgstr "是否允许终端注册,当所有终端启动后,为了安全应该关闭" -#: settings/serializers/settings.py:160 +#: settings/serializers/settings.py:165 msgid "Limit the number of login failures" msgstr "限制登录失败次数" -#: settings/serializers/settings.py:164 +#: settings/serializers/settings.py:169 msgid "Block logon interval" msgstr "禁止登录时间间隔" -#: settings/serializers/settings.py:165 +#: settings/serializers/settings.py:170 msgid "" "Tip: (unit/minute) if the user has failed to log in for a limited number of " "times, no login is allowed during this time interval." msgstr "" "提示:(单位:分)当用户登录失败次数达到限制后,那么在此时间间隔内禁止登录" -#: settings/serializers/settings.py:169 +#: settings/serializers/settings.py:174 msgid "Connection max idle time" msgstr "连接最大空闲时间" -#: settings/serializers/settings.py:170 +#: settings/serializers/settings.py:175 msgid "If idle time more than it, disconnect connection Unit: minute" msgstr "提示:如果超过该配置没有操作,连接会被断开 (单位:分)" -#: settings/serializers/settings.py:174 +#: settings/serializers/settings.py:179 msgid "User password expiration" msgstr "用户密码过期时间" -#: settings/serializers/settings.py:175 +#: settings/serializers/settings.py:180 msgid "" "Tip: (unit: day) If the user does not update the password during the time, " "the user password will expire failure;The password expiration reminder mail " @@ -2460,150 +2719,162 @@ msgstr "" "提示:(单位:天)如果用户在此期间没有更新密码,用户密码将过期失效; 密码过期" "提醒邮件将在密码过期前5天内由系统(每天)自动发送给用户" -#: settings/serializers/settings.py:179 +#: settings/serializers/settings.py:184 msgid "Number of repeated historical passwords" msgstr "不能设置近几次密码" -#: settings/serializers/settings.py:180 +#: settings/serializers/settings.py:185 msgid "" "Tip: When the user resets the password, it cannot be the previous n " "historical passwords of the user" msgstr "提示:用户重置密码时,不能为该用户前几次使用过的密码" -#: settings/serializers/settings.py:184 +#: settings/serializers/settings.py:189 msgid "Password minimum length" msgstr "密码最小长度" -#: settings/serializers/settings.py:187 +#: settings/serializers/settings.py:193 +msgid "Admin user password minimum length" +msgstr "管理员密码最小长度" + +#: settings/serializers/settings.py:196 msgid "Must contain capital" msgstr "必须包含大写字符" -#: settings/serializers/settings.py:189 +#: settings/serializers/settings.py:198 msgid "Must contain lowercase" msgstr "必须包含小写字符" -#: settings/serializers/settings.py:190 +#: settings/serializers/settings.py:199 msgid "Must contain numeric" msgstr "必须包含数字" -#: settings/serializers/settings.py:191 +#: settings/serializers/settings.py:200 msgid "Must contain special" msgstr "必须包含特殊字符" -#: settings/serializers/settings.py:192 +#: settings/serializers/settings.py:201 msgid "Insecure command alert" msgstr "危险命令告警" -#: settings/serializers/settings.py:194 +#: settings/serializers/settings.py:203 msgid "Email recipient" msgstr "邮件收件人" -#: settings/serializers/settings.py:195 +#: settings/serializers/settings.py:204 msgid "Multiple user using , split" msgstr "多个用户,使用 , 分割" -#: settings/serializers/settings.py:203 +#: settings/serializers/settings.py:212 msgid "Enable WeCom Auth" msgstr "启用企业微信认证" -#: settings/serializers/settings.py:210 +#: settings/serializers/settings.py:219 msgid "Enable DingTalk Auth" msgstr "启用钉钉认证" -#: settings/utils/ldap.py:416 +#: settings/serializers/settings.py:225 +msgid "Enable FeiShu Auth" +msgstr "启用飞书认证" + +#: settings/utils/ldap.py:412 +msgid "ldap:// or ldaps:// protocol is used." +msgstr "使用 ldap:// 或 ldaps:// 协议" + +#: settings/utils/ldap.py:423 msgid "Host or port is disconnected: {}" msgstr "主机或端口不可连接: {}" -#: settings/utils/ldap.py:418 +#: settings/utils/ldap.py:425 msgid "The port is not the port of the LDAP service: {}" msgstr "端口不是LDAP服务端口: {}" -#: settings/utils/ldap.py:420 +#: settings/utils/ldap.py:427 msgid "Please add certificate: {}" msgstr "请添加证书" -#: settings/utils/ldap.py:422 settings/utils/ldap.py:449 -#: settings/utils/ldap.py:479 settings/utils/ldap.py:507 +#: settings/utils/ldap.py:431 settings/utils/ldap.py:458 +#: settings/utils/ldap.py:488 settings/utils/ldap.py:516 msgid "Unknown error: {}" msgstr "未知错误: {}" -#: settings/utils/ldap.py:436 +#: settings/utils/ldap.py:445 msgid "Bind DN or Password incorrect" msgstr "绑定DN或密码错误" -#: settings/utils/ldap.py:443 +#: settings/utils/ldap.py:452 msgid "Please enter Bind DN: {}" msgstr "请输入绑定DN: {}" -#: settings/utils/ldap.py:445 +#: settings/utils/ldap.py:454 msgid "Please enter Password: {}" msgstr "请输入密码: {}" -#: settings/utils/ldap.py:447 +#: settings/utils/ldap.py:456 msgid "Please enter correct Bind DN and Password: {}" msgstr "请输入正确的绑定DN和密码: {}" -#: settings/utils/ldap.py:465 +#: settings/utils/ldap.py:474 msgid "Invalid User OU or User search filter: {}" msgstr "不合法的用户OU或用户过滤器: {}" -#: settings/utils/ldap.py:496 +#: settings/utils/ldap.py:505 msgid "LDAP User attr map not include: {}" msgstr "LDAP属性映射没有包含: {}" -#: settings/utils/ldap.py:503 +#: settings/utils/ldap.py:512 msgid "LDAP User attr map is not dict" msgstr "LDAP属性映射不合法" -#: settings/utils/ldap.py:522 +#: settings/utils/ldap.py:531 msgid "LDAP authentication is not enabled" msgstr "LDAP认证没有启用" -#: settings/utils/ldap.py:540 +#: settings/utils/ldap.py:549 msgid "Error (Invalid LDAP server): {}" msgstr "错误 (不合法的LDAP服务器地址): {}" -#: settings/utils/ldap.py:542 +#: settings/utils/ldap.py:551 msgid "Error (Invalid Bind DN): {}" msgstr "错误(不合法的绑定DN): {}" -#: settings/utils/ldap.py:544 +#: settings/utils/ldap.py:553 msgid "Error (Invalid LDAP User attr map): {}" msgstr "错误(不合法的LDAP属性映射): {}" -#: settings/utils/ldap.py:546 +#: settings/utils/ldap.py:555 msgid "Error (Invalid User OU or User search filter): {}" msgstr "错误(不合法的用户OU或用户过滤器): {}" -#: settings/utils/ldap.py:548 +#: settings/utils/ldap.py:557 msgid "Error (Not enabled LDAP authentication): {}" msgstr "错误(没有启用LDAP认证): {}" -#: settings/utils/ldap.py:550 +#: settings/utils/ldap.py:559 msgid "Error (Unknown): {}" msgstr "错误(未知): {}" -#: settings/utils/ldap.py:553 +#: settings/utils/ldap.py:562 msgid "Succeed: Match {} s user" msgstr "成功匹配 {} 个用户" -#: settings/utils/ldap.py:586 +#: settings/utils/ldap.py:595 msgid "Authentication failed (configuration incorrect): {}" msgstr "认证失败(配置错误): {}" -#: settings/utils/ldap.py:588 +#: settings/utils/ldap.py:597 msgid "Authentication failed (before login check failed): {}" msgstr "认证失败(登录前检查失败): {}" -#: settings/utils/ldap.py:590 +#: settings/utils/ldap.py:599 msgid "Authentication failed (username or password incorrect): {}" msgstr "认证失败 (用户名或密码不正确): {}" -#: settings/utils/ldap.py:592 +#: settings/utils/ldap.py:601 msgid "Authentication failed (Unknown): {}" msgstr "认证失败: (未知): {}" -#: settings/utils/ldap.py:595 +#: settings/utils/ldap.py:604 msgid "Authentication success: {}" msgstr "认证成功: {}" @@ -2671,7 +2942,7 @@ msgstr "用户页面" #: templates/_header_bar.html:78 msgid "API Key" -msgstr "" +msgstr "API Key" #: templates/_header_bar.html:79 msgid "Logout" @@ -2774,10 +3045,6 @@ msgstr "命令过滤" msgid "Platform list" msgstr "平台列表" -#: templates/_nav.html:60 -msgid "Applications" -msgstr "应用管理" - #: templates/_nav.html:64 templates/_nav.html:82 templates/_nav_user.html:16 msgid "RemoteApp" msgstr "远程应用" @@ -2863,7 +3130,7 @@ msgstr "改密日志" #: templates/_nav.html:163 msgid "XPack" -msgstr "" +msgstr "XPack" #: templates/_nav.html:171 msgid "Account list" @@ -2881,10 +3148,6 @@ msgstr "系统设置" msgid "My assets" msgstr "我的资产" -#: templates/_nav_user.html:10 -msgid "My Applications" -msgstr "我的应用" - #: templates/_nav_user.html:31 msgid "Command execution" msgstr "命令执行" @@ -3148,7 +3411,7 @@ msgstr "会话ID" #: terminal/backends/command/serializers.py:19 msgid "Risk level display" -msgstr "风险等级(显示名称)" +msgstr "风险等级名称" #: terminal/backends/command/serializers.py:21 msgid "Timestamp" @@ -3227,6 +3490,10 @@ msgstr "默认存储" msgid "Args" msgstr "参数" +#: terminal/models/task.py:18 +msgid "Kwargs" +msgstr "" + #: terminal/models/terminal.py:103 msgid "type" msgstr "类型" @@ -3366,18 +3633,18 @@ msgstr "桶名称" #: terminal/serializers/storage.py:30 msgid "Access key" -msgstr "" +msgstr "Access key" #: terminal/serializers/storage.py:34 msgid "Secret key" -msgstr "" +msgstr "Secret key" #: terminal/serializers/storage.py:39 terminal/serializers/storage.py:51 #: terminal/serializers/storage.py:81 terminal/serializers/storage.py:91 msgid "Endpoint" msgstr "端点" -#: terminal/serializers/storage.py:66 xpack/plugins/cloud/models.py:199 +#: terminal/serializers/storage.py:66 xpack/plugins/cloud/models.py:209 msgid "Region" msgstr "地域" @@ -3421,7 +3688,7 @@ msgstr "文档类型" msgid "Ignore Certificate Verification" msgstr "忽略证书认证" -#: terminal/serializers/terminal.py:73 terminal/serializers/terminal.py:81 +#: terminal/serializers/terminal.py:78 terminal/serializers/terminal.py:86 msgid "Not found" msgstr "没有发现" @@ -3806,7 +4073,7 @@ msgstr "工单已处理 - {} ({})" msgid "Your ticket has been processed, processor - {}" msgstr "你的工单已被处理, 处理人 - {}" -#: users/api/user.py:215 +#: users/api/user.py:214 msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" @@ -3881,11 +4148,11 @@ msgid "Public key should not be the same as your old one." msgstr "不能和原来的密钥相同" #: users/forms/profile.py:149 users/serializers/profile.py:74 -#: users/serializers/profile.py:148 users/serializers/profile.py:161 +#: users/serializers/profile.py:149 users/serializers/profile.py:162 msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/profile.py:160 users/models/user.py:581 +#: users/forms/profile.py:160 users/models/user.py:586 #: users/templates/users/user_password_update.html:48 msgid "Public key" msgstr "SSH公钥" @@ -3902,39 +4169,39 @@ msgstr "系统审计员" msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:530 +#: users/models/user.py:535 msgid "Local" msgstr "数据库" -#: users/models/user.py:564 +#: users/models/user.py:569 msgid "Avatar" msgstr "头像" -#: users/models/user.py:567 +#: users/models/user.py:572 msgid "Wechat" msgstr "微信" -#: users/models/user.py:578 +#: users/models/user.py:583 msgid "Private key" msgstr "ssh私钥" -#: users/models/user.py:597 +#: users/models/user.py:602 msgid "Source" msgstr "来源" -#: users/models/user.py:601 +#: users/models/user.py:606 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:604 +#: users/models/user.py:609 msgid "Need update password" msgstr "需要更新密码" -#: users/models/user.py:755 +#: users/models/user.py:765 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:758 +#: users/models/user.py:768 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" @@ -3954,7 +4221,7 @@ msgstr "新密码不能是最近 {} 次的密码" msgid "The newly set password is inconsistent" msgstr "两次密码不一致" -#: users/serializers/profile.py:119 users/serializers/user.py:79 +#: users/serializers/profile.py:120 users/serializers/user.py:79 msgid "Is first login" msgstr "首次登录" @@ -3967,7 +4234,7 @@ msgid "Set password" msgstr "设置密码" #: users/serializers/user.py:27 xpack/plugins/change_auth_plan/models.py:61 -#: xpack/plugins/change_auth_plan/serializers.py:29 +#: xpack/plugins/change_auth_plan/serializers.py:30 msgid "Password strategy" msgstr "密码策略" @@ -4028,17 +4295,16 @@ msgid "name not unique" msgstr "名称重复" #: users/templates/users/_base_otp.html:14 -msgid "Security token validation" -msgstr "安全令牌验证" +msgid "Please enter the password of" +msgstr "请输入" -#: users/templates/users/_base_otp.html:14 xpack/plugins/cloud/models.py:78 -#: xpack/plugins/cloud/serializers.py:178 -msgid "Account" +#: users/templates/users/_base_otp.html:14 +msgid "account" msgstr "账户" #: users/templates/users/_base_otp.html:14 -msgid "Follow these steps to complete the binding operation" -msgstr "请按照以下步骤完成绑定操作" +msgid "to complete the binding operation" +msgstr "的密码完成绑定操作" #: users/templates/users/_granted_assets.html:7 msgid "Loading" @@ -4322,7 +4588,7 @@ msgstr "" "
\n" " " -#: users/utils.py:116 users/views/profile/reset.py:124 +#: users/utils.py:116 users/views/profile/reset.py:125 msgid "Reset password success" msgstr "重置密码成功" @@ -4526,36 +4792,28 @@ msgstr "" "
\n" " " -#: users/views/profile/otp.py:107 users/views/profile/otp.py:146 -#: users/views/profile/otp.py:166 +#: users/views/profile/otp.py:122 users/views/profile/otp.py:161 +#: users/views/profile/otp.py:181 msgid "MFA code invalid, or ntp sync server time" msgstr "MFA验证码不正确,或者服务器端时间不对" -#: users/views/profile/otp.py:190 +#: users/views/profile/otp.py:205 msgid "MFA enable success" msgstr "多因子认证启用成功" -#: users/views/profile/otp.py:191 +#: users/views/profile/otp.py:206 msgid "MFA enable success, return login page" msgstr "多因子认证启用成功,返回到登录页面" -#: users/views/profile/otp.py:193 +#: users/views/profile/otp.py:208 msgid "MFA disable success" msgstr "多因子认证禁用成功" -#: users/views/profile/otp.py:194 +#: users/views/profile/otp.py:209 msgid "MFA disable success, return login page" msgstr "多因子认证禁用成功,返回登录页面" -#: users/views/profile/password.py:44 -msgid "Password update" -msgstr "密码更新" - -#: users/views/profile/password.py:60 users/views/profile/reset.py:105 -msgid "* Your password does not meet the requirements" -msgstr "* 您的密码不符合要求" - -#: users/views/profile/password.py:75 +#: users/views/profile/password.py:30 msgid "Password invalid" msgstr "用户名或密码无效" @@ -4582,19 +4840,23 @@ msgid "" "password" msgstr "用户来自 {} 请去相应系统修改密码" -#: users/views/profile/reset.py:83 users/views/profile/reset.py:93 +#: users/views/profile/reset.py:83 users/views/profile/reset.py:94 msgid "Token invalid or expired" msgstr "Token错误或失效" -#: users/views/profile/reset.py:98 +#: users/views/profile/reset.py:99 msgid "User auth from {}, go there change password" msgstr "用户认证源来自 {}, 请去相应系统修改密码" -#: users/views/profile/reset.py:111 +#: users/views/profile/reset.py:106 +msgid "* Your password does not meet the requirements" +msgstr "* 您的密码不符合要求" + +#: users/views/profile/reset.py:112 msgid "* The new password cannot be the last {} passwords" msgstr "* 新密码不能是最近 {} 次的密码" -#: users/views/profile/reset.py:125 +#: users/views/profile/reset.py:126 msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" @@ -4661,19 +4923,19 @@ msgstr "步骤" msgid "Change auth plan task" msgstr "改密计划任务" -#: xpack/plugins/change_auth_plan/serializers.py:52 +#: xpack/plugins/change_auth_plan/serializers.py:56 msgid "Run times" msgstr "执行次数" -#: xpack/plugins/change_auth_plan/serializers.py:68 +#: xpack/plugins/change_auth_plan/serializers.py:72 msgid "* Please enter custom password" msgstr "* 请输入自定义密码" -#: xpack/plugins/change_auth_plan/serializers.py:78 +#: xpack/plugins/change_auth_plan/serializers.py:82 msgid "* Please enter the correct password length" msgstr "* 请输入正确的密码长度" -#: xpack/plugins/change_auth_plan/serializers.py:81 +#: xpack/plugins/change_auth_plan/serializers.py:85 msgid "* Password length range 6-30 bits" msgstr "* 密码长度范围 6-30 位" @@ -4781,39 +5043,47 @@ msgstr "云服务商" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:160 +#: xpack/plugins/cloud/models.py:82 xpack/plugins/cloud/serializers.py:204 +msgid "Account" +msgstr "账户" + +#: xpack/plugins/cloud/models.py:85 xpack/plugins/cloud/serializers.py:179 msgid "Regions" msgstr "地域" -#: xpack/plugins/cloud/models.py:84 +#: xpack/plugins/cloud/models.py:88 msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:182 +#: xpack/plugins/cloud/models.py:102 xpack/plugins/cloud/serializers.py:186 +msgid "IP network segment group" +msgstr "IP网段组" + +#: xpack/plugins/cloud/models.py:105 xpack/plugins/cloud/serializers.py:208 msgid "Always update" msgstr "总是更新" -#: xpack/plugins/cloud/models.py:101 +#: xpack/plugins/cloud/models.py:111 msgid "Date last sync" msgstr "最后同步日期" -#: xpack/plugins/cloud/models.py:112 xpack/plugins/cloud/models.py:153 +#: xpack/plugins/cloud/models.py:122 xpack/plugins/cloud/models.py:163 msgid "Sync instance task" msgstr "同步实例任务" -#: xpack/plugins/cloud/models.py:164 xpack/plugins/cloud/models.py:209 +#: xpack/plugins/cloud/models.py:174 xpack/plugins/cloud/models.py:219 msgid "Date sync" msgstr "同步日期" -#: xpack/plugins/cloud/models.py:189 +#: xpack/plugins/cloud/models.py:199 msgid "Sync task" msgstr "同步任务" -#: xpack/plugins/cloud/models.py:193 +#: xpack/plugins/cloud/models.py:203 msgid "Sync instance task history" msgstr "同步实例任务历史" -#: xpack/plugins/cloud/models.py:196 +#: xpack/plugins/cloud/models.py:206 msgid "Instance" msgstr "实例" @@ -4957,47 +5227,56 @@ msgstr "西南-贵阳1" msgid "EU-Paris" msgstr "欧洲-巴黎" -#: xpack/plugins/cloud/serializers.py:19 +#: xpack/plugins/cloud/serializers.py:21 msgid "AccessKey ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:22 +#: xpack/plugins/cloud/serializers.py:24 msgid "AccessKey Secret" msgstr "" -#: xpack/plugins/cloud/serializers.py:28 +#: xpack/plugins/cloud/serializers.py:30 msgid "Client ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:31 +#: xpack/plugins/cloud/serializers.py:33 msgid "Client Secret" msgstr "" -#: xpack/plugins/cloud/serializers.py:34 +#: xpack/plugins/cloud/serializers.py:36 msgid "Tenant ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:37 +#: xpack/plugins/cloud/serializers.py:39 msgid "Subscription ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:49 +#: xpack/plugins/cloud/serializers.py:51 msgid "This field is required" msgstr "这个字段是必填项" -#: xpack/plugins/cloud/serializers.py:83 xpack/plugins/cloud/serializers.py:87 +#: xpack/plugins/cloud/serializers.py:85 xpack/plugins/cloud/serializers.py:89 msgid "API Endpoint" msgstr "API 端点" -#: xpack/plugins/cloud/serializers.py:158 +#: xpack/plugins/cloud/serializers.py:171 +msgid "" +"The IP address that is first matched to will be used as the IP of the " +"created asset.
The default * indicates a random match.
Format for " +"comma-delimited string, Such as: 192.168.1.0/24, 10.1.1.1-10.1.1.20" +msgstr "" +"第一个匹配到的 IP 地址将被用作创建的资产的 IP。
默认值 * 表示随机匹配。" +"
格式为以逗号分隔的字符串,例如:192.168.1.0/24,10.1.1.1-10.1.1.20" + +#: xpack/plugins/cloud/serializers.py:177 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers.py:159 +#: xpack/plugins/cloud/serializers.py:178 msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:181 +#: xpack/plugins/cloud/serializers.py:207 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -5089,3 +5368,88 @@ msgstr "旗舰版" #: xpack/plugins/license/models.py:77 msgid "Community edition" msgstr "社区版" + +#~ msgid "Append SSH KEY" +#~ msgstr "追加新密钥" + +#~ msgid "Empty and append SSH KEY" +#~ msgstr "清空所有密钥再追加新密钥" + +#~ msgid "Empty current user and append SSH KEY" +#~ msgstr "清空当前账号密钥再追加新密钥" + +#, fuzzy +#~| msgid "Hostname strategy" +#~ msgid "SSH key strategy" +#~ msgstr "主机名策略" + +#~ msgid "Manual trigger" +#~ msgstr "手动触发" + +#~ msgid "Timing trigger" +#~ msgstr "定时触发" + +#~ msgid "Trigger mode" +#~ msgstr "触发模式" + +#~ msgid "Change Password" +#~ msgstr "修改密码" + +#~ msgid "Change SSH Key" +#~ msgstr "修改密钥" + +#, fuzzy +#~| msgid "SSH Key Reset" +#~ msgid "SSH Key strategy" +#~ msgstr "重置SSH密钥" + +#~ msgid "Require password strategy perform setting" +#~ msgstr "需要密码策略执行设置" + +#~ msgid "Require password perform setting" +#~ msgstr "需要密码执行设置" + +#~ msgid "Require password rule perform setting" +#~ msgstr "需要密码规则执行设置" + +#~ msgid "Require ssh key strategy or ssh key perform setting" +#~ msgstr "需要ssh密钥策略或ssh密钥执行设置" + +#~ msgid "Category(Display)" +#~ msgstr "类别 (显示名称)" + +#~ msgid "Type(Dispaly)" +#~ msgstr "类型 (显示名称)" + +#~ msgid "Users name" +#~ msgstr "用户名" + +#~ msgid "User groups name" +#~ msgstr "用户组名称" + +#~ msgid "Assets name" +#~ msgstr "资产名称" + +#~ msgid "System users name" +#~ msgstr "系统用户名称" + +#~ msgid "Admin user MFA auth" +#~ msgstr "所有管理员启用 MFA" + +#~ msgid "Admin user enable MFA" +#~ msgstr "强制管理员启用 MFA" + +#~ msgid "Password update" +#~ msgstr "密码更新" + +#~ msgid "All user enable MFA" +#~ msgstr "强制所有用户启用 MFA" + +#~ msgid "Application category" +#~ msgstr "应用类别" + +#~ msgid "Application type" +#~ msgstr "应用类型" + +#~ msgid "Trigger" +#~ msgstr "触发" diff --git a/apps/locale/zh/LC_MESSAGES/djangojs.mo b/apps/locale/zh/LC_MESSAGES/djangojs.mo index 24e68caf5..c02ebe0ed 100644 Binary files a/apps/locale/zh/LC_MESSAGES/djangojs.mo and b/apps/locale/zh/LC_MESSAGES/djangojs.mo differ diff --git a/apps/locale/zh/LC_MESSAGES/djangojs.po b/apps/locale/zh/LC_MESSAGES/djangojs.po index 6e75dd502..0cae14858 100644 --- a/apps/locale/zh/LC_MESSAGES/djangojs.po +++ b/apps/locale/zh/LC_MESSAGES/djangojs.po @@ -73,7 +73,7 @@ msgid "" "User list、User group、Asset list、Domain list、Admin user、System user、" "Labels、Asset permission" msgstr "" -"用户列表、用户组、资产列表、网域列表、管理用户、系统用户、标签管理、资产授权" +"用户列表、用户组、资产列表、网域列表、特权用户、系统用户、标签管理、资产授权" "规则" #: static/js/jumpserver.js:416 diff --git a/apps/notifications/api/notifications.py b/apps/notifications/api/notifications.py index 7d176e7ae..5c726e201 100644 --- a/apps/notifications/api/notifications.py +++ b/apps/notifications/api/notifications.py @@ -4,7 +4,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from common.drf.api import JmsGenericViewSet +from common.drf.api import JMSGenericViewSet from notifications.notifications import system_msgs from notifications.models import SystemMsgSubscription from notifications.backends import BACKEND @@ -30,7 +30,7 @@ class BackendListView(APIView): class SystemMsgSubscriptionViewSet(ListModelMixin, UpdateModelMixin, - JmsGenericViewSet): + JMSGenericViewSet): lookup_field = 'message_type' queryset = SystemMsgSubscription.objects.all() serializer_classes = { diff --git a/apps/notifications/api/site_msgs.py b/apps/notifications/api/site_msgs.py index 2f8ba7e15..632101384 100644 --- a/apps/notifications/api/site_msgs.py +++ b/apps/notifications/api/site_msgs.py @@ -5,7 +5,7 @@ from rest_framework.decorators import action from common.http import is_true from common.permissions import IsValidUser from common.const.http import GET, PATCH, POST -from common.drf.api import JmsGenericViewSet +from common.drf.api import JMSGenericViewSet from ..serializers import ( SiteMessageDetailSerializer, SiteMessageIdsSerializer, SiteMessageSendSerializer, @@ -16,7 +16,7 @@ from ..filters import SiteMsgFilter __all__ = ('SiteMessageViewSet', ) -class SiteMessageViewSet(ListModelMixin, RetrieveModelMixin, JmsGenericViewSet): +class SiteMessageViewSet(ListModelMixin, RetrieveModelMixin, JMSGenericViewSet): permission_classes = (IsValidUser,) serializer_classes = { 'default': SiteMessageDetailSerializer, diff --git a/apps/notifications/backends/__init__.py b/apps/notifications/backends/__init__.py index 4e2633072..11a95cf40 100644 --- a/apps/notifications/backends/__init__.py +++ b/apps/notifications/backends/__init__.py @@ -5,6 +5,7 @@ from .dingtalk import DingTalk from .email import Email from .site_msg import SiteMessage from .wecom import WeCom +from .feishu import FeiShu class BACKEND(models.TextChoices): @@ -12,6 +13,7 @@ class BACKEND(models.TextChoices): WECOM = 'wecom', _('WeCom') DINGTALK = 'dingtalk', _('DingTalk') SITE_MSG = 'site_msg', _('Site message') + FEISHU = 'feishu', _('FeiShu') @property def client(self): @@ -19,7 +21,8 @@ class BACKEND(models.TextChoices): self.EMAIL: Email, self.WECOM: WeCom, self.DINGTALK: DingTalk, - self.SITE_MSG: SiteMessage + self.SITE_MSG: SiteMessage, + self.FEISHU: FeiShu, }[self] return client diff --git a/apps/notifications/backends/dingtalk.py b/apps/notifications/backends/dingtalk.py index ef5e9a9c6..83add673e 100644 --- a/apps/notifications/backends/dingtalk.py +++ b/apps/notifications/backends/dingtalk.py @@ -1,5 +1,4 @@ from django.conf import settings - from common.message.backends.dingtalk import DingTalk as Client from .base import BackendBase diff --git a/apps/notifications/backends/feishu.py b/apps/notifications/backends/feishu.py new file mode 100644 index 000000000..90547299c --- /dev/null +++ b/apps/notifications/backends/feishu.py @@ -0,0 +1,19 @@ +from django.conf import settings + +from common.message.backends.feishu import FeiShu as Client +from .base import BackendBase + + +class FeiShu(BackendBase): + account_field = 'feishu_id' + is_enable_field_in_settings = 'AUTH_FEISHU' + + def __init__(self): + self.client = Client( + app_id=settings.FEISHU_APP_ID, + app_secret=settings.FEISHU_APP_SECRET + ) + + def send_msg(self, users, msg): + accounts, __, __ = self.get_accounts(users) + return self.client.send_text(accounts, msg) diff --git a/apps/notifications/notifications.py b/apps/notifications/notifications.py index bbf9fe7ee..82086c618 100644 --- a/apps/notifications/notifications.py +++ b/apps/notifications/notifications.py @@ -92,8 +92,9 @@ class Message(metaclass=MessageType): def get_email_msg(self) -> dict: msg = self.get_common_msg() + subject = f'{msg[:20]} ...' if len(msg) >= 20 else msg return { - 'subject': msg, + 'subject': subject, 'message': msg } diff --git a/apps/ops/notifications.py b/apps/ops/notifications.py index 4a65d8a4e..289871d74 100644 --- a/apps/ops/notifications.py +++ b/apps/ops/notifications.py @@ -4,8 +4,9 @@ from notifications.notifications import SystemMessage from notifications.models import SystemMsgSubscription from users.models import User from notifications.backends import BACKEND +from terminal.models import Status, Terminal -__all__ = ('ServerPerformanceMessage',) +__all__ = ('ServerPerformanceMessage', 'ServerPerformanceCheckUtil') class ServerPerformanceMessage(SystemMessage): @@ -13,13 +14,11 @@ class ServerPerformanceMessage(SystemMessage): category_label = _('Operations') message_type_label = _('Server performance') - def __init__(self, path, usage): - self.path = path - self.usage = usage + def __init__(self, msg): + self._msg = msg def get_common_msg(self): - msg = _("Disk used more than 80%: {} => {}").format(self.path, self.usage.percent) - return msg + return self._msg @classmethod def post_insert_to_db(cls, subscription: SystemMsgSubscription): @@ -27,3 +26,78 @@ class ServerPerformanceMessage(SystemMessage): subscription.users.add(*admins) subscription.receive_backends = [BACKEND.EMAIL] subscription.save() + + +class ServerPerformanceCheckUtil(object): + items_mapper = { + 'is_alive': { + 'default': False, + 'max_threshold': False, + 'alarm_msg_format': _('[Alive] The terminal is offline: {name}') + }, + 'disk_usage': { + 'default': 0, + 'max_threshold': 80, + 'alarm_msg_format': _( + '[Disk] Disk used more than {max_threshold}%: => {value} ({name})' + ) + }, + 'memory_usage': { + 'default': 0, + 'max_threshold': 85, + 'alarm_msg_format': _( + '[Memory] Memory used more than {max_threshold}%: => {value} ({name})' + ), + }, + 'cpu_load': { + 'default': 0, + 'max_threshold': 5, + 'alarm_msg_format': _( + '[CPU] CPU load more than {max_threshold}: => {value} ({name})' + ), + }, + } + + def __init__(self): + self.alarm_messages = [] + self._terminals = [] + self._terminal = None + + def check_and_publish(self): + self.check() + self.publish() + + def check(self): + self.alarm_messages = [] + self.initial_terminals() + for item, data in self.items_mapper.items(): + for self._terminal in self._terminals: + self.check_item(item, data) + + def check_item(self, item, data): + default = data['default'] + max_threshold = data['max_threshold'] + value = getattr(self._terminal.stat, item, default) + print(value, max_threshold, self._terminal.name, self._terminal.id) + if isinstance(value, bool) and value != max_threshold: + return + elif isinstance(value, (int, float)) and value < max_threshold: + return + msg = data['alarm_msg_format'] + msg = msg.format(max_threshold=max_threshold, value=value, name=self._terminal.name) + self.alarm_messages.append(msg) + + def publish(self): + if not self.alarm_messages: + return + msg = '
'.join(self.alarm_messages) + ServerPerformanceMessage(msg).publish() + + def initial_terminals(self): + terminals = [] + for terminal in Terminal.objects.filter(is_deleted=False): + if not terminal.is_active: + continue + terminal.stat = Status.get_terminal_latest_stat(terminal) + terminals.append(terminal) + self._terminals = terminals diff --git a/apps/ops/tasks.py b/apps/ops/tasks.py index 60f639668..00a0027cd 100644 --- a/apps/ops/tasks.py +++ b/apps/ops/tasks.py @@ -9,7 +9,7 @@ from celery.exceptions import SoftTimeLimitExceeded from django.utils import timezone from django.utils.translation import ugettext_lazy as _ -from common.utils import get_logger, get_object_or_none, get_disk_usage, get_log_keep_day +from common.utils import get_logger, get_object_or_none, get_log_keep_day from orgs.utils import tmp_to_root_org, tmp_to_org from .celery.decorator import ( register_as_period_task, after_app_shutdown_clean_periodic, @@ -20,7 +20,7 @@ from .celery.utils import ( disable_celery_periodic_task, delete_celery_periodic_task ) from .models import Task, CommandExecution, CeleryTask -from .notifications import ServerPerformanceMessage +from .notifications import ServerPerformanceCheckUtil logger = get_logger(__file__) @@ -132,18 +132,7 @@ def create_or_update_registered_periodic_tasks(): @shared_task @register_as_period_task(interval=3600) def check_server_performance_period(): - if not settings.DISK_CHECK_ENABLED: - return - usages = get_disk_usage() - uncheck_paths = ['/etc', '/boot'] - - for path, usage in usages.items(): - need_check = True - for uncheck_path in uncheck_paths: - if path.startswith(uncheck_path): - need_check = False - if need_check and usage.percent > 80: - ServerPerformanceMessage(path=path, usage=usage).publish() + ServerPerformanceCheckUtil().check_and_publish() @shared_task(queue="ansible") diff --git a/apps/orgs/mixins/models.py b/apps/orgs/mixins/models.py index 66edc37e0..498d90a5a 100644 --- a/apps/orgs/mixins/models.py +++ b/apps/orgs/mixins/models.py @@ -49,6 +49,9 @@ class OrgModelMixin(models.Model): def save(self, *args, **kwargs): org = get_current_org() + # 这里不可以优化成, 因为 root 组织下可以设置组织 id 来保存 + # if org.is_root() and not self.org_id: + # raise ... if org.is_root(): if not self.org_id: raise ValidationError('Please save in a organization') diff --git a/apps/orgs/models.py b/apps/orgs/models.py index 9eb2eb9ad..efbee20b2 100644 --- a/apps/orgs/models.py +++ b/apps/orgs/models.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from common.utils import lazyproperty, settings from common.const import choices +from common.tree import TreeNode from common.db.models import TextChoices @@ -233,6 +234,20 @@ class Organization(models.Model): with tmp_to_org(self): return resource_model.objects.all().count() + def as_tree_node(self, pid, opened=True): + node = TreeNode(**{ + 'id': str(self.id), + 'name': self.name, + 'title': self.name, + 'pId': pid, + 'open': opened, + 'isParent': True, + 'meta': { + 'type': 'org' + } + }) + return node + def _convert_to_uuid_set(users): rst = set() diff --git a/apps/perms/api/application/application_permission.py b/apps/perms/api/application/application_permission.py index e7b449b5a..c03bf9150 100644 --- a/apps/perms/api/application/application_permission.py +++ b/apps/perms/api/application/application_permission.py @@ -12,10 +12,14 @@ class ApplicationPermissionViewSet(BasePermissionViewSet): """ model = ApplicationPermission serializer_class = serializers.ApplicationPermissionSerializer - filterset_fields = ['name', 'category', 'type'] - search_fields = filterset_fields + filterset_fields = { + 'name': ['exact'], + 'category': ['exact'], + 'type': ['exact', 'in'], + } + search_fields = ['name', 'category', 'type'] custom_filter_fields = BasePermissionViewSet.custom_filter_fields + [ - 'application_id', 'application' + 'application_id', 'application', 'app', 'app_name' ] def get_queryset(self): @@ -25,12 +29,15 @@ class ApplicationPermissionViewSet(BasePermissionViewSet): return queryset def filter_application(self, queryset): - application_id = self.request.query_params.get('application_id') - application_name = self.request.query_params.get('application') - if application_id: - applications = Application.objects.filter(pk=application_id) - elif application_name: - applications = Application.objects.filter(name=application_name) + app_id = self.request.query_params.get('application_id') or \ + self.request.query_params.get('app') + app_name = self.request.query_params.get('application') or \ + self.request.query_params.get('app_name') + + if app_id: + applications = Application.objects.filter(pk=app_id) + elif app_name: + applications = Application.objects.filter(name=app_name) else: return queryset if not applications: 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 6916f6f29..a88217e27 100644 --- a/apps/perms/api/application/user_permission/user_permission_applications.py +++ b/apps/perms/api/application/user_permission/user_permission_applications.py @@ -4,6 +4,7 @@ from rest_framework.generics import ListAPIView from rest_framework.response import Response from common.mixins.api import CommonApiMixin +from common.tree import TreeNodeSerializer from applications.api.mixin import ( SerializeApplicationToTreeNodeMixin ) @@ -25,7 +26,13 @@ __all__ = [ class AllGrantedApplicationsMixin(CommonApiMixin, ListAPIView): only_fields = serializers.ApplicationGrantedSerializer.Meta.only_fields serializer_class = serializers.ApplicationGrantedSerializer - filterset_fields = ['id', 'name', 'category', 'type', 'comment'] + filterset_fields = { + 'id': ['exact'], + 'name': ['exact'], + 'category': ['exact'], + 'type': ['exact', 'in'], + 'comment': ['exact'], + } search_fields = ['name', 'comment'] user: None @@ -46,11 +53,13 @@ class ApplicationsAsTreeMixin(SerializeApplicationToTreeNodeMixin): """ 将应用序列化成树的结构返回 """ + serializer_class = TreeNodeSerializer def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) - data = self.serialize_applications_with_org(queryset) - return Response(data=data) + tree_nodes = self.serialize_applications_with_org(queryset) + serializer = self.get_serializer(tree_nodes, many=True) + return Response(data=serializer.data) class UserAllGrantedApplicationsAsTreeApi(ApplicationsAsTreeMixin, UserAllGrantedApplicationsApi): diff --git a/apps/perms/api/asset/user_permission/user_permission_assets/mixin.py b/apps/perms/api/asset/user_permission/user_permission_assets/mixin.py index 2464787d6..1b6b8e00f 100644 --- a/apps/perms/api/asset/user_permission/user_permission_assets/mixin.py +++ b/apps/perms/api/asset/user_permission/user_permission_assets/mixin.py @@ -99,5 +99,6 @@ class AssetsTreeFormatMixin(SerializeToTreeNodeMixin): # 如果用户搜索的条件不精准,会导致返回大量的无意义数据。 # 这里限制一下返回数据的最大条数 queryset = queryset[:999] + queryset = sorted(queryset, key=lambda asset: asset.hostname) data = self.serialize_assets(queryset, None) return Response(data=data) diff --git a/apps/perms/models/application_permission.py b/apps/perms/models/application_permission.py index d16b154ae..a405e667c 100644 --- a/apps/perms/models/application_permission.py +++ b/apps/perms/models/application_permission.py @@ -8,7 +8,7 @@ from django.utils.translation import ugettext_lazy as _ from common.utils import lazyproperty from .base import BasePermission from users.models import User -from applications.const import ApplicationCategoryChoices, ApplicationTypeChoices +from applications.const import AppCategory, AppType __all__ = [ 'ApplicationPermission', @@ -17,10 +17,10 @@ __all__ = [ class ApplicationPermission(BasePermission): category = models.CharField( - max_length=16, choices=ApplicationCategoryChoices.choices, verbose_name=_('Category') + max_length=16, choices=AppCategory.choices, verbose_name=_('Category') ) type = models.CharField( - max_length=16, choices=ApplicationTypeChoices.choices, verbose_name=_('Type') + max_length=16, choices=AppType.choices, verbose_name=_('Type') ) applications = models.ManyToManyField( 'applications.Application', related_name='granted_by_permissions', blank=True, @@ -38,15 +38,15 @@ class ApplicationPermission(BasePermission): @property def category_remote_app(self): - return self.category == ApplicationCategoryChoices.remote_app.value + return self.category == AppCategory.remote_app.value @property def category_db(self): - return self.category == ApplicationCategoryChoices.db.value + return self.category == AppCategory.db.value @property def category_cloud(self): - return self.category == ApplicationCategoryChoices.cloud.value + return self.category == AppCategory.cloud.value @lazyproperty def users_amount(self): diff --git a/apps/perms/serializers/application/permission.py b/apps/perms/serializers/application/permission.py index ce820b858..ecf6ae3f4 100644 --- a/apps/perms/serializers/application/permission.py +++ b/apps/perms/serializers/application/permission.py @@ -13,29 +13,42 @@ __all__ = [ class ApplicationPermissionSerializer(BulkOrgResourceModelSerializer): - category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category')) - type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type')) - is_valid = serializers.BooleanField(read_only=True) - is_expired = serializers.BooleanField(read_only=True) + category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display')) + type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) + is_valid = serializers.BooleanField(read_only=True, label=_('Is valid')) + is_expired = serializers.BooleanField(read_only=True, label=_("Is expired")) class Meta: model = ApplicationPermission fields_mini = ['id', 'name'] fields_small = fields_mini + [ - 'category', 'category_display', 'type', 'type_display', 'is_active', 'is_expired', - 'is_valid', 'created_by', 'date_created', 'date_expired', 'date_start', 'comment' + 'category', 'category_display', 'type', 'type_display', + 'is_active', 'is_expired', 'is_valid', + 'created_by', 'date_created', 'date_expired', 'date_start', 'comment' ] fields_m2m = [ 'users', 'user_groups', 'applications', 'system_users', - 'users_amount', 'user_groups_amount', 'applications_amount', 'system_users_amount', + 'users_amount', 'user_groups_amount', 'applications_amount', + 'system_users_amount', ] fields = fields_small + fields_m2m read_only_fields = ['created_by', 'date_created'] + extra_kwargs = { + 'is_expired': {'label': _('Is expired')}, + 'is_valid': {'label': _('Is valid')}, + 'actions': {'label': _('Actions')}, + 'users_amount': {'label': _('Users amount')}, + 'user_groups_amount': {'label': _('User groups amount')}, + 'system_users_amount': {'label': _('System users amount')}, + 'applications_amount': {'label': _('Applications amount')}, + } @classmethod def setup_eager_loading(cls, queryset): """ Perform necessary eager loading of data. """ - queryset = queryset.prefetch_related('users', 'user_groups', 'applications', 'system_users') + queryset = queryset.prefetch_related( + 'users', 'user_groups', 'applications', 'system_users' + ) return queryset def validate_applications(self, applications): diff --git a/apps/perms/serializers/asset/permission.py b/apps/perms/serializers/asset/permission.py index 3f0a01061..824a25292 100644 --- a/apps/perms/serializers/asset/permission.py +++ b/apps/perms/serializers/asset/permission.py @@ -3,9 +3,7 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ - -from django.db.models import Prefetch, Q - +from django.db.models import Q from orgs.mixins.serializers import BulkOrgResourceModelSerializer from perms.models import AssetPermission, Action @@ -40,14 +38,14 @@ class ActionsDisplayField(ActionsField): class AssetPermissionSerializer(BulkOrgResourceModelSerializer): - actions = ActionsField(required=False, allow_null=True) - is_valid = serializers.BooleanField(read_only=True) + actions = ActionsField(required=False, allow_null=True, label=_("Actions")) + is_valid = serializers.BooleanField(read_only=True, label=_("Is valid")) is_expired = serializers.BooleanField(read_only=True, label=_('Is expired')) - users_display = serializers.ListField(child=serializers.CharField(), label=_('Users name'), required=False) - user_groups_display = serializers.ListField(child=serializers.CharField(), label=_('User groups name'), required=False) - assets_display = serializers.ListField(child=serializers.CharField(), label=_('Assets name'), required=False) - nodes_display = serializers.ListField(child=serializers.CharField(), label=_('Nodes name'), required=False) - system_users_display = serializers.ListField(child=serializers.CharField(), label=_('System users name'), required=False) + users_display = serializers.ListField(child=serializers.CharField(), label=_('Users display'), required=False) + user_groups_display = serializers.ListField(child=serializers.CharField(), label=_('User groups display'), required=False) + assets_display = serializers.ListField(child=serializers.CharField(), label=_('Assets display'), required=False) + nodes_display = serializers.ListField(child=serializers.CharField(), label=_('Nodes display'), required=False) + system_users_display = serializers.ListField(child=serializers.CharField(), label=_('System users display'), required=False) class Meta: model = AssetPermission @@ -58,8 +56,8 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer): 'date_start', 'comment' ] fields_m2m = [ - 'users', 'users_display', 'user_groups', 'user_groups_display', 'assets', 'assets_display', - 'nodes', 'nodes_display', 'system_users', 'system_users_display', + 'users', 'users_display', 'user_groups', 'user_groups_display', 'assets', + 'assets_display', 'nodes', 'nodes_display', 'system_users', 'system_users_display', 'users_amount', 'user_groups_amount', 'assets_amount', 'nodes_amount', 'system_users_amount', ] @@ -79,7 +77,9 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer): @classmethod def setup_eager_loading(cls, queryset): """ Perform necessary eager loading of data. """ - queryset = queryset.prefetch_related('users', 'user_groups', 'assets', 'nodes', 'system_users') + queryset = queryset.prefetch_related( + 'users', 'user_groups', 'assets', 'nodes', 'system_users' + ) return queryset def to_internal_value(self, data): @@ -93,33 +93,39 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer): if system_user and system_user.id not in system_users: system_users.append(system_user.id) data['system_users'] = system_users - return super().to_internal_value(data) - def perform_display_create(self, instance, **kwargs): + @staticmethod + def perform_display_create(instance, **kwargs): # 用户 users_to_set = User.objects.filter( - Q(name__in=kwargs.get('users_display')) | Q(username__in=kwargs.get('users_display')) + Q(name__in=kwargs.get('users_display')) | + Q(username__in=kwargs.get('users_display')) ).distinct() instance.users.add(*users_to_set) # 用户组 - user_groups_to_set = UserGroup.objects.filter(name__in=kwargs.get('user_groups_display')).distinct() + user_groups_to_set = UserGroup.objects.filter( + name__in=kwargs.get('user_groups_display') + ).distinct() instance.user_groups.add(*user_groups_to_set) # 资产 assets_to_set = Asset.objects.filter( - Q(ip__in=kwargs.get('assets_display')) | Q(hostname__in=kwargs.get('assets_display')) + Q(ip__in=kwargs.get('assets_display')) | + Q(hostname__in=kwargs.get('assets_display')) ).distinct() instance.assets.add(*assets_to_set) # 节点 - nodes_to_set = Node.objects.filter(full_value__in=kwargs.get('nodes_display')).distinct() + nodes_to_set = Node.objects.filter( + full_value__in=kwargs.get('nodes_display') + ).distinct() instance.nodes.add(*nodes_to_set) def create(self, validated_data): display = { - 'users_display' : validated_data.pop('users_display', ''), - 'user_groups_display' : validated_data.pop('user_groups_display', ''), - 'assets_display' : validated_data.pop('assets_display', ''), - 'nodes_display' : validated_data.pop('nodes_display', '') + 'users_display': validated_data.pop('users_display', ''), + 'user_groups_display': validated_data.pop('user_groups_display', ''), + 'assets_display': validated_data.pop('assets_display', ''), + 'nodes_display': validated_data.pop('nodes_display', '') } instance = super().create(validated_data) self.perform_display_create(instance, **display) diff --git a/apps/perms/utils/asset/user_permission.py b/apps/perms/utils/asset/user_permission.py index 7f3a0941f..b91039dcc 100644 --- a/apps/perms/utils/asset/user_permission.py +++ b/apps/perms/utils/asset/user_permission.py @@ -30,7 +30,7 @@ logger = get_logger(__name__) def get_user_all_asset_perm_ids(user) -> set: asset_perm_ids = set() - user_perm_id = AssetPermission.users.through.objects\ + user_perm_id = AssetPermission.users.through.objects \ .filter(user_id=user.id) \ .values_list('assetpermission_id', flat=True) \ .distinct() @@ -41,8 +41,8 @@ def get_user_all_asset_perm_ids(user) -> set: .values_list('usergroup_id', flat=True) \ .distinct() group_ids = list(group_ids) - groups_perm_id = AssetPermission.user_groups.through.objects\ - .filter(usergroup_id__in=group_ids)\ + groups_perm_id = AssetPermission.user_groups.through.objects \ + .filter(usergroup_id__in=group_ids) \ .values_list('assetpermission_id', flat=True) \ .distinct() asset_perm_ids.update(groups_perm_id) @@ -203,7 +203,8 @@ class UserGrantedTreeRefreshController: logger.info(f'Rebuild user tree: user={self.user} org={current_org}') utils = UserGrantedTreeBuildUtils(user) utils.rebuild_user_granted_tree() - logger.info(f'Rebuild user tree ok: cost={time.time() - t_start} user={self.user} org={current_org}') + logger.info( + f'Rebuild user tree ok: cost={time.time() - t_start} user={self.user} org={current_org}') class UserGrantedUtilsBase: @@ -547,14 +548,16 @@ class UserGrantedNodesQueryUtils(UserGrantedUtilsBase): if not key: return self.get_top_level_nodes() + nodes = PermNode.objects.none() + if key == PermNode.FAVORITE_NODE_KEY: + return nodes + node = PermNode.objects.get(key=key) granted_status = node.get_granted_status(self.user) if granted_status == NodeFrom.granted: nodes = PermNode.objects.filter(parent_key=key) elif granted_status in (NodeFrom.asset, NodeFrom.child): nodes = self.get_indirect_granted_node_children(key) - else: - nodes = PermNode.objects.none() nodes = self.sort(nodes) return nodes diff --git a/apps/settings/api/__init__.py b/apps/settings/api/__init__.py index 39e009ed5..d7cfa4cec 100644 --- a/apps/settings/api/__init__.py +++ b/apps/settings/api/__init__.py @@ -2,3 +2,4 @@ from .common import * from .ldap import * from .wecom import * from .dingtalk import * +from .feishu import * diff --git a/apps/settings/api/common.py b/apps/settings/api/common.py index 1cb39e62d..5f0e6f89c 100644 --- a/apps/settings/api/common.py +++ b/apps/settings/api/common.py @@ -122,6 +122,7 @@ class PublicSettingApi(generics.RetrieveAPIView): "TICKETS_ENABLED": settings.TICKETS_ENABLED, "PASSWORD_RULE": { 'SECURITY_PASSWORD_MIN_LENGTH': settings.SECURITY_PASSWORD_MIN_LENGTH, + 'SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH': settings.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH, 'SECURITY_PASSWORD_UPPER_CASE': settings.SECURITY_PASSWORD_UPPER_CASE, 'SECURITY_PASSWORD_LOWER_CASE': settings.SECURITY_PASSWORD_LOWER_CASE, 'SECURITY_PASSWORD_NUMBER': settings.SECURITY_PASSWORD_NUMBER, @@ -129,6 +130,8 @@ class PublicSettingApi(generics.RetrieveAPIView): }, "AUTH_WECOM": settings.AUTH_WECOM, "AUTH_DINGTALK": settings.AUTH_DINGTALK, + "AUTH_FEISHU": settings.AUTH_FEISHU, + 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED } } return instance @@ -146,6 +149,7 @@ class SettingsApi(generics.RetrieveUpdateAPIView): 'email_content': serializers.EmailContentSettingSerializer, 'wecom': serializers.WeComSettingSerializer, 'dingtalk': serializers.DingTalkSettingSerializer, + 'feishu': serializers.FeiShuSettingSerializer, } def get_serializer_class(self): @@ -159,7 +163,8 @@ class SettingsApi(generics.RetrieveUpdateAPIView): def get_object(self): items = self.get_fields().keys() - return {item: getattr(settings, item) for item in items} + obj = {item: getattr(settings, item) for item in items} + return obj def parse_serializer_data(self, serializer): data = [] diff --git a/apps/settings/api/feishu.py b/apps/settings/api/feishu.py new file mode 100644 index 000000000..3e3d720b1 --- /dev/null +++ b/apps/settings/api/feishu.py @@ -0,0 +1,41 @@ +from rest_framework.views import Response +from rest_framework.generics import GenericAPIView +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + +from settings.models import Setting +from common.permissions import IsSuperUser +from common.message.backends.feishu import FeiShu + +from .. import serializers + + +class FeiShuTestingAPI(GenericAPIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.FeiShuSettingSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + app_id = serializer.validated_data['FEISHU_APP_ID'] + app_secret = serializer.validated_data.get('FEISHU_APP_SECRET') + + if not app_secret: + secret = Setting.objects.filter(name='FEISHU_APP_SECRET').first() + if secret: + app_secret = secret.cleaned_value + + app_secret = app_secret or '' + + try: + feishu = FeiShu(app_id=app_id, app_secret=app_secret) + feishu.send_text(['test'], 'test') + return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')}) + except APIException as e: + try: + error = e.detail['errmsg'] + except: + error = e.detail + return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error}) diff --git a/apps/settings/api/ldap.py b/apps/settings/api/ldap.py index dd5c15563..e1a34da67 100644 --- a/apps/settings/api/ldap.py +++ b/apps/settings/api/ldap.py @@ -1,10 +1,7 @@ # -*- coding: utf-8 -*- # -import json import threading -from collections.abc import Iterable -from smtplib import SMTPSenderRefused from rest_framework import generics from rest_framework.views import Response, APIView from orgs.models import Organization @@ -12,14 +9,14 @@ from django.utils.translation import ugettext_lazy as _ from ..utils import ( LDAPServerUtil, LDAPCacheUtil, LDAPImportUtil, LDAPSyncUtil, - LDAP_USE_CACHE_FLAGS, LDAPTestUtil, ObjectDict + LDAP_USE_CACHE_FLAGS, LDAPTestUtil ) from ..tasks import sync_ldap_user -from common.permissions import IsOrgAdmin, IsSuperUser +from common.permissions import IsSuperUser from common.utils import get_logger, is_uuid from ..serializers import ( - MailTestSerializer, LDAPTestConfigSerializer, LDAPUserSerializer, - PublicSettingSerializer, LDAPTestLoginSerializer, SettingsSerializer + LDAPTestConfigSerializer, LDAPUserSerializer, + LDAPTestLoginSerializer ) from orgs.utils import current_org from users.models import User diff --git a/apps/settings/migrations/0002_auto_20210729_1546.py b/apps/settings/migrations/0002_auto_20210729_1546.py new file mode 100644 index 000000000..0b460f9a3 --- /dev/null +++ b/apps/settings/migrations/0002_auto_20210729_1546.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1 on 2021-07-29 07:46 + +from django.db import migrations + + +def migrate_security_mfa_auth(apps, schema_editor): + setting_model = apps.get_model("settings", "Setting") + db_alias = schema_editor.connection.alias + + mfa_setting = setting_model.objects.using(db_alias).filter(name='SECURITY_MFA_AUTH').first() + if not mfa_setting: + return + + if mfa_setting.value == 'true': + mfa_setting.value = 1 + else: + mfa_setting.value = 0 + mfa_setting.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('settings', '0001_initial'), + ] + + operations = [ + migrations.RunPython(migrate_security_mfa_auth) + ] diff --git a/apps/settings/serializers/ldap.py b/apps/settings/serializers/ldap.py index 06ae3051f..1ccc02c26 100644 --- a/apps/settings/serializers/ldap.py +++ b/apps/settings/serializers/ldap.py @@ -1,6 +1,7 @@ # coding: utf-8 # +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers __all__ = [ diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index f2e11c17f..b9159c2cd 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -7,6 +7,7 @@ __all__ = [ 'BasicSettingSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer', 'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer', 'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer', + 'FeiShuSettingSerializer', ] @@ -143,9 +144,13 @@ class TerminalSettingSerializer(serializers.Serializer): class SecuritySettingSerializer(serializers.Serializer): - SECURITY_MFA_AUTH = serializers.BooleanField( - required=False, label=_("Global MFA auth"), - help_text=_('All user enable MFA') + SECURITY_MFA_AUTH = serializers.ChoiceField( + choices=( + [0, _('Disable')], + [1, _('All users')], + [2, _('Only admin users')], + ), + required=False, label=_("Global MFA auth") ) SECURITY_COMMAND_EXECUTION = serializers.BooleanField( required=False, label=_('Batch command execution'), @@ -183,6 +188,10 @@ class SecuritySettingSerializer(serializers.Serializer): min_value=6, max_value=30, required=True, label=_('Password minimum length') ) + SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH = serializers.IntegerField( + min_value=6, max_value=30, required=True, + label=_('Admin user password minimum length') + ) SECURITY_PASSWORD_UPPER_CASE = serializers.BooleanField( required=False, label=_('Must contain capital') ) @@ -210,6 +219,12 @@ class DingTalkSettingSerializer(serializers.Serializer): AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth')) +class FeiShuSettingSerializer(serializers.Serializer): + FEISHU_APP_ID = serializers.CharField(max_length=256, required=True, label='App ID') + FEISHU_APP_SECRET = serializers.CharField(max_length=256, required=False, label='App Secret', write_only=True) + AUTH_FEISHU = serializers.BooleanField(default=False, label=_('Enable FeiShu Auth')) + + class SettingsSerializer( BasicSettingSerializer, EmailSettingSerializer, @@ -219,6 +234,7 @@ class SettingsSerializer( SecuritySettingSerializer, WeComSettingSerializer, DingTalkSettingSerializer, + FeiShuSettingSerializer, ): # encrypt_fields 现在使用 write_only 来判断了 diff --git a/apps/settings/urls/api_urls.py b/apps/settings/urls/api_urls.py index 86dfc6847..bd423611f 100644 --- a/apps/settings/urls/api_urls.py +++ b/apps/settings/urls/api_urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path('ldap/cache/refresh/', api.LDAPCacheRefreshAPI.as_view(), name='ldap-cache-refresh'), path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'), path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'), + path('feishu/testing/', api.FeiShuTestingAPI.as_view(), name='feishu-testing'), path('setting/', api.SettingsApi.as_view(), name='settings-setting'), path('public/', api.PublicSettingApi.as_view(), name='public-setting'), diff --git a/apps/settings/utils/ldap.py b/apps/settings/utils/ldap.py index 366d21614..890ac32e8 100644 --- a/apps/settings/utils/ldap.py +++ b/apps/settings/utils/ldap.py @@ -406,11 +406,18 @@ class LDAPTestUtil(object): # test server uri + def _check_server_uri(self): + if not any([self.config.server_uri.startswith('ldap://') or + self.config.server_uri.startswith('ldaps://')]): + err = _('ldap:// or ldaps:// protocol is used.') + raise LDAPInvalidServerError(err) + def _test_server_uri(self): self._test_connection_bind() def test_server_uri(self): try: + self._check_server_uri() self._test_server_uri() except LDAPSocketOpenError as e: error = _("Host or port is disconnected: {}").format(e) @@ -418,6 +425,8 @@ class LDAPTestUtil(object): error = _('The port is not the port of the LDAP service: {}').format(e) except LDAPSocketReceiveError as e: error = _('Please add certificate: {}').format(e) + except LDAPInvalidServerError as e: + error = str(e) except Exception as e: error = _('Unknown error: {}').format(e) else: diff --git a/apps/static/img/login_feishu_logo.png b/apps/static/img/login_feishu_logo.png new file mode 100644 index 000000000..054f350f5 Binary files /dev/null and b/apps/static/img/login_feishu_logo.png differ diff --git a/apps/templates/_nav_user.html b/apps/templates/_nav_user.html index c1da1f58b..7e36f3dcc 100644 --- a/apps/templates/_nav_user.html +++ b/apps/templates/_nav_user.html @@ -7,7 +7,7 @@
  • - {% trans 'My Applications' %} + {% trans 'My applications' %}
  • -
    {% trans 'Security token validation' %}  {% trans 'Account' %} {{ user.username }}  {% trans 'Follow these steps to complete the binding operation' %}
    +
    {% trans 'Please enter the password of' %} {% trans 'account' %} {{ user.username }} {% trans 'to complete the binding operation' %}

    {% block content %} {% endblock %} diff --git a/apps/users/utils.py b/apps/users/utils.py index 374ead56f..8b77a3fa0 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -295,10 +295,12 @@ def check_otp_code(otp_secret_key, otp_code): return totp.verify(otp=otp_code, valid_window=otp_valid_window) -def get_password_check_rules(): +def get_password_check_rules(user): check_rules = [] for rule in settings.SECURITY_PASSWORD_RULES: key = "id_{}".format(rule.lower()) + if user.is_org_admin and rule == 'SECURITY_PASSWORD_MIN_LENGTH': + rule = 'SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH' value = getattr(settings, rule) if not value: continue @@ -306,7 +308,7 @@ def get_password_check_rules(): return check_rules -def check_password_rules(password): +def check_password_rules(password, user): pattern = r"^" if settings.SECURITY_PASSWORD_UPPER_CASE: pattern += '(?=.*[A-Z])' @@ -317,7 +319,11 @@ def check_password_rules(password): if settings.SECURITY_PASSWORD_SPECIAL_CHAR: pattern += '(?=.*[`~!@#\$%\^&\*\(\)-=_\+\[\]\{\}\|;:\'\",\.<>\/\?])' pattern += '[a-zA-Z\d`~!@#\$%\^&\*\(\)-=_\+\[\]\{\}\|;:\'\",\.<>\/\?]' - pattern += '.{' + str(settings.SECURITY_PASSWORD_MIN_LENGTH-1) + ',}$' + if user.is_org_admin: + min_length = settings.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH + else: + min_length = settings.SECURITY_PASSWORD_MIN_LENGTH + pattern += '.{' + str(min_length-1) + ',}$' match_obj = re.match(pattern, password) return bool(match_obj) diff --git a/apps/users/views/profile/otp.py b/apps/users/views/profile/otp.py index caed50532..7966dda8e 100644 --- a/apps/users/views/profile/otp.py +++ b/apps/users/views/profile/otp.py @@ -2,6 +2,7 @@ import time from django.urls import reverse_lazy, reverse +from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ from django.views.generic.base import TemplateView from django.views.generic.edit import FormView @@ -33,6 +34,16 @@ logger = get_logger(__name__) class UserOtpEnableStartView(UserVerifyPasswordView): template_name = 'users/user_otp_check_password.html' + def form_valid(self, form): + # 开启了 OTP IN RADIUS 就不用绑定了 + resp = super().form_valid(form) + if settings.OTP_IN_RADIUS: + user_id = self.request.session.get('user_id') + user = get_object_or_404(User, id=user_id) + user.enable_mfa() + user.save() + return resp + def get_success_url(self): if settings.OTP_IN_RADIUS: success_url = reverse_lazy('authentication:user-otp-settings-success') @@ -85,7 +96,11 @@ class UserOtpEnableBindView(AuthMixin, TemplateView, FormView): session_user = get_object_or_none(User, pk=user_id) if session_user: - if all((is_auth_password_time_valid(self.request.session), session_user.mfa_enabled, not session_user.otp_secret_key)): + if all(( + is_auth_password_time_valid(self.request.session), + session_user.mfa_enabled, + not session_user.otp_secret_key + )): return True return False diff --git a/apps/users/views/profile/password.py b/apps/users/views/profile/password.py index e2cd8f8e2..e2dde8e92 100644 --- a/apps/users/views/profile/password.py +++ b/apps/users/views/profile/password.py @@ -4,65 +4,20 @@ import time from django.conf import settings from django.contrib.auth import authenticate from django.shortcuts import redirect -from django.urls import reverse_lazy from django.utils.translation import ugettext as _ -from django.views.generic.edit import UpdateView, FormView -from django.contrib.auth import logout as auth_logout +from django.views.generic.edit import FormView from common.utils import get_logger -from common.permissions import ( - IsValidUser, - UserCanUpdatePassword -) -from common.mixins.views import PermissionsMixin from ... import forms -from ...models import User from ...utils import ( get_user_or_pre_auth_user, - check_password_rules, get_password_check_rules, ) -__all__ = ['UserPasswordUpdateView', 'UserVerifyPasswordView'] +__all__ = ['UserVerifyPasswordView'] logger = get_logger(__name__) -class UserPasswordUpdateView(PermissionsMixin, UpdateView): - template_name = 'users/user_password_update.html' - model = User - form_class = forms.UserPasswordForm - success_url = reverse_lazy('users:user-profile') - permission_classes = [IsValidUser, UserCanUpdatePassword] - - def get_object(self, queryset=None): - return self.request.user - - def get_context_data(self, **kwargs): - check_rules = get_password_check_rules() - context = { - 'app': _('Users'), - 'action': _('Password update'), - 'password_check_rules': check_rules, - } - kwargs.update(context) - return super().get_context_data(**kwargs) - - def get_success_url(self): - auth_logout(self.request) - return super().get_success_url() - - def form_valid(self, form): - password = form.cleaned_data.get('new_password') - is_ok = check_password_rules(password) - if not is_ok: - form.add_error( - "new_password", - _("* Your password does not meet the requirements") - ) - return self.form_invalid(form) - return super().form_valid(form) - - class UserVerifyPasswordView(FormView): template_name = 'users/user_password_verify.html' form_class = forms.UserCheckPasswordForm @@ -74,9 +29,6 @@ class UserVerifyPasswordView(FormView): if not user: form.add_error("password", _("Password invalid")) return self.form_invalid(form) - if not user.mfa_is_otp(): - user.enable_mfa() - user.save() self.request.session['user_id'] = str(user.id) self.request.session['auth_password'] = 1 self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS diff --git a/apps/users/views/profile/reset.py b/apps/users/views/profile/reset.py index ba9cfd9b7..46d09ab7e 100644 --- a/apps/users/views/profile/reset.py +++ b/apps/users/views/profile/reset.py @@ -82,8 +82,9 @@ class UserResetPasswordView(FormView): if not user: context['errors'] = _('Token invalid or expired') context['token_invalid'] = True - check_rules = get_password_check_rules() - context['password_check_rules'] = check_rules + else: + check_rules = get_password_check_rules(user) + context['password_check_rules'] = check_rules return context def form_valid(self, form): @@ -100,7 +101,7 @@ class UserResetPasswordView(FormView): return self.form_invalid(form) password = form.cleaned_data['new_password'] - is_ok = check_password_rules(password) + is_ok = check_password_rules(password, user) if not is_ok: error = _('* Your password does not meet the requirements') form.add_error('new_password', error) diff --git a/config_example.yml b/config_example.yml index 9fa4c1814..d2124df2e 100644 --- a/config_example.yml +++ b/config_example.yml @@ -129,3 +129,24 @@ REDIS_PORT: 6379 # # Windows 登录跳过手动输入密码 # WINDOWS_SKIP_ALL_MANUAL_PASSWORD: False + +# 是否开启 Luna 水印 +# SECURITY_WATERMARK_ENABLED: False + +# 健康检查的token,默认是空 +# HEALTH_CHECK_TOKEN: '' + +# 浏览器关闭页面后,会话过期 +# SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE: False + +# 每次api请求,session续期 +# SESSION_SAVE_EVERY_REQUEST: True + +# 硬盘检查 +# DISK_CHECK_ENABLED: True + +# 仅允许用户从来源处登录 +# ONLY_ALLOW_AUTH_FROM_SOURCE: False + +# 仅允许已存在的用户登录,不允许第三方认证后,自动创建用户 +# ONLY_ALLOW_EXIST_USER_AUTH: False diff --git a/entrypoint.sh b/entrypoint.sh index fa798f642..fe81b1470 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -13,6 +13,9 @@ service="${2-all}" trap cleanup EXIT if [[ "$action" == "bash" || "$action" == "sh" ]];then bash +elif [[ "$action" == "sleep" ]];then + echo "Sleep 365 days" + sleep 365d else python jms "${action}" "${service}" fi diff --git a/jms b/jms index 6bd71849d..475ef75ca 100755 --- a/jms +++ b/jms @@ -3,19 +3,11 @@ import os import subprocess -import threading -import datetime import logging import logging.handlers -import psutil import time import argparse import sys -import shutil -import signal -from collections import defaultdict -import daemon -from daemon import pidfile BASE_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, BASE_DIR) @@ -40,29 +32,12 @@ except ImportError as e: sys.exit(1) os.environ["PYTHONIOENCODING"] = "UTF-8" -APPS_DIR = os.path.join(BASE_DIR, 'apps') -LOG_DIR = os.path.join(BASE_DIR, 'logs') -TMP_DIR = os.path.join(BASE_DIR, 'tmp') -HTTP_HOST = CONFIG.HTTP_BIND_HOST or '127.0.0.1' -HTTP_PORT = CONFIG.HTTP_LISTEN_PORT or 8080 -WS_PORT = CONFIG.WS_LISTEN_PORT or 8082 -DEBUG = CONFIG.DEBUG or False -LOG_LEVEL = CONFIG.LOG_LEVEL or 'INFO' -START_TIMEOUT = 40 -WORKERS = 4 -DAEMON = False -LOG_KEEP_DAYS = 7 logging.basicConfig( format='%(asctime)s %(message)s', level=logging.INFO, datefmt='%Y-%m-%d %H:%M:%S' ) -EXIT_EVENT = threading.Event() -LOCK = threading.Lock() -files_preserve = [] -STOP_TIMEOUT = 10 - logger = logging.getLogger() try: @@ -76,20 +51,21 @@ def check_database_connection(): os.chdir(os.path.join(BASE_DIR, 'apps')) for i in range(60): logging.info("Check database connection ...") - code = subprocess.call("python manage.py showmigrations users ", shell=True) - if code == 0: + _code = subprocess.call("python manage.py showmigrations users ", shell=True) + if _code == 0: logging.info("Database connect success") return time.sleep(1) - logging.error("Connection database failed, exist") + logging.error("Connection database failed, exit") sys.exit(10) 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) + _apps_dir = os.path.join(BASE_DIR, 'apps') + _cmd = "python manage.py showmigrations | grep '\[.\]' | grep -v '\[X\]'" + _code = subprocess.call(_cmd, shell=True, cwd=_apps_dir) - if code == 1: + if _code == 1: return # for i in range(3): # print("!!! Warning: Has SQL migrations not perform, 有 SQL 变更没有执行") @@ -98,10 +74,10 @@ def check_migrations(): def expire_caches(): - apps_dir = os.path.join(BASE_DIR, 'apps') - code = subprocess.call("python manage.py expire_caches", shell=True, cwd=apps_dir) + _apps_dir = os.path.join(BASE_DIR, 'apps') + _code = subprocess.call("python manage.py expire_caches", shell=True, cwd=_apps_dir) - if code == 1: + if _code == 1: return @@ -109,417 +85,34 @@ 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 ...") - subprocess.call('python3 manage.py migrate', shell=True) + _code = subprocess.call('python3 manage.py migrate', shell=True) + if _code == 0: + return + logging.error('Perform migrate failed, exit') + sys.exit(11) def collect_static(): logging.info("Collect static files") os.chdir(os.path.join(BASE_DIR, 'apps')) - command = 'python3 manage.py collectstatic --no-input -c &> /dev/null ' - subprocess.call(command, shell=True) + _cmd = 'python3 manage.py collectstatic --no-input -c &> /dev/null ' + subprocess.call(_cmd, shell=True) logging.info("Collect static files done") -def prepare(): - check_database_connection() - check_migrations() - upgrade_db() - expire_caches() - - -def check_pid(pid): - """ Check For the existence of a unix pid. """ - try: - os.kill(pid, 0) - except (OSError, ProcessLookupError): - return False - else: - return True - - -def get_pid_file_path(s): - return os.path.join(TMP_DIR, '{}.pid'.format(s)) - - -def get_log_file_path(s): - return os.path.join(LOG_DIR, '{}.log'.format(s)) - - -def get_pid_from_file(path): - if os.path.isfile(path): - with open(path) as f: - try: - return int(f.read().strip()) - except ValueError: - return 0 - return 0 - - -def get_pid(s): - pid_file = get_pid_file_path(s) - return get_pid_from_file(pid_file) - - -def is_running(s, unlink=True): - pid_file = get_pid_file_path(s) - - if os.path.isfile(pid_file): - pid = get_pid(s) - if pid == 0: - return False - elif check_pid(pid): - return True - - if unlink: - os.unlink(pid_file) - return False - - -def parse_service(s): - web_services = ['gunicorn', 'flower', 'daphne'] - celery_services = [ - "celery_ansible", "celery_default" - ] - task_services = celery_services + ['beat'] - all_services = web_services + task_services - if s == 'all': - return all_services - elif s == "web": - return web_services - elif s == 'ws': - return ['daphne'] - elif s == "task": - return task_services - elif s == "celery": - return celery_services - elif "," in s: - services = set() - for i in s.split(','): - services.update(parse_service(i)) - return services - else: - return [s] - - -def get_start_gunicorn_kwargs(): - print("\n- Start Gunicorn WSGI HTTP Server") - prepare() - bind = '{}:{}'.format(HTTP_HOST, HTTP_PORT) - log_format = '%(h)s %(t)s %(L)ss "%(r)s" %(s)s %(b)s ' - - cmd = [ - 'gunicorn', 'jumpserver.wsgi', - '-b', bind, - '-k', 'gthread', - '--threads', '10', - '-w', str(WORKERS), - '--max-requests', '4096', - '--access-logformat', log_format, - '--access-logfile', '-' - ] - - if DEBUG: - cmd.append('--reload') - return {'cmd': cmd, 'cwd': APPS_DIR} - - -def get_start_daphne_kwargs(): - print("\n- Start Daphne ASGI WS Server") - cmd = [ - 'daphne', 'jumpserver.asgi:application', - '-b', HTTP_HOST, - '-p', str(WS_PORT), - ] - return {'cmd': cmd, 'cwd': APPS_DIR} - - -def get_start_celery_ansible_kwargs(): - print("\n- Start Celery as Distributed Task Queue: Ansible") - return get_start_worker_kwargs('ansible', 10) - - -def get_start_celery_default_kwargs(): - print("\n- Start Celery as Distributed Task Queue: Celery") - return get_start_worker_kwargs('celery', 10) - - -def get_start_worker_kwargs(queue, num): - # Todo: Must set this environment, otherwise not no ansible result return - os.environ.setdefault('PYTHONOPTIMIZE', '1') - os.environ.setdefault('ANSIBLE_FORCE_COLOR', 'True') - - if os.getuid() == 0: - os.environ.setdefault('C_FORCE_ROOT', '1') - server_hostname = os.environ.get("SERVER_HOSTNAME") - if not server_hostname: - server_hostname = '%h' - - cmd = [ - 'celery', 'worker', - '-P', 'threads', - '-A', 'ops', - '-l', 'INFO', - '-c', str(num), - '-Q', queue, - '-n', '{}@{}'.format(queue, server_hostname) - ] - return {"cmd": cmd, "cwd": APPS_DIR} - - -def get_start_flower_kwargs(): - print("\n- Start Flower as Task Monitor") - if os.getuid() == 0: - os.environ.setdefault('C_FORCE_ROOT', '1') - - cmd = [ - 'celery', 'flower', - '-A', 'ops', - '-l', 'INFO', - '--url_prefix=/core/flower', - '--auto_refresh=False', - '--max_tasks=1000', - '--tasks_columns=uuid,name,args,state,received,started,runtime,worker' - ] - return {"cmd": cmd, "cwd": APPS_DIR} - - -def get_start_beat_kwargs(): - print("\n- Start Beat as Periodic Task Scheduler") - utils_dir = os.path.join(BASE_DIR, 'utils') - cmd = [ - sys.executable, 'start_celery_beat.py', - ] - return {"cmd": cmd, 'cwd': utils_dir} - - -processes = {} - - -def watch_services(): - max_retry = 3 - services_retry = defaultdict(int) - stopped_services = {} - - def check_services(): - now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') - for s, p in processes.items(): - print("{} Check service status: {} -> ".format(now, s), end='') - try: - p.wait(timeout=1) # 不wait,子进程可能无法回收 - except subprocess.TimeoutExpired: - pass - ok = is_running(s) - if not ok: - stopped_services[s] = '' - print("stopped with code: {}({})".format(p.returncode, p.pid)) - else: - print("running at {}".format(p.pid)) - stopped_services.pop(s, None) - services_retry.pop(s, None) - - def retry_start_stopped_services(): - for s in stopped_services: - if services_retry[s] > max_retry: - logging.info("Service start failed, exit: ", s) - EXIT_EVENT.set() - break - - p = start_service(s) - logging.info("> Find {} stopped, retry {}, {}".format( - s, services_retry[s] + 1, p.pid) - ) - processes[s] = p - services_retry[s] += 1 - - def rotate_log_if_need(): - now = datetime.datetime.now() - tm = now.strftime('%H:%M') - if tm != '23:59': - return - suffix = now.strftime('%Y-%m-%d') - services = list(processes.keys()) - services.append('jms') - - for s in services: - log_path = get_log_file_path(s) - log_dir = os.path.dirname(log_path) - filename = os.path.basename(log_path) - pre_log_dir = os.path.join(log_dir, suffix) - if not os.path.exists(pre_log_dir): - os.mkdir(pre_log_dir) - - pre_log_path = os.path.join(pre_log_dir, filename) - if os.path.isfile(log_path) and not os.path.isfile(pre_log_path): - logging.info("Rotate log file: {} => {}".format(log_path, pre_log_path)) - shutil.copy(log_path, pre_log_path) - with open(log_path, 'w') as f: - pass - some_days_ago = now - datetime.timedelta(days=LOG_KEEP_DAYS) - days_ago_dir = os.path.join(LOG_DIR, some_days_ago.strftime('%Y-%m-%d')) - if os.path.exists(days_ago_dir): - logger.info("Remove old log: {}".format(days_ago_dir)) - shutil.rmtree(days_ago_dir, ignore_errors=True) - - while not EXIT_EVENT.is_set(): - try: - with LOCK: - check_services() - retry_start_stopped_services() - rotate_log_if_need() - time.sleep(30) - except KeyboardInterrupt: - print("Start stop service") - time.sleep(1) - break - clean_up() - - -def start_service(s): - services_kwargs = { - "gunicorn": get_start_gunicorn_kwargs, - "celery_ansible": get_start_celery_ansible_kwargs, - "celery_default": get_start_celery_default_kwargs, - "beat": get_start_beat_kwargs, - "flower": get_start_flower_kwargs, - "daphne": get_start_daphne_kwargs, - } - - kwargs = services_kwargs.get(s)() - pid_file = get_pid_file_path(s) - - if os.path.isfile(pid_file): - os.unlink(pid_file) - cmd = kwargs.pop('cmd') - - log_file_path = get_log_file_path(s) - log_file_f = open(log_file_path, 'a') - files_preserve.append(log_file_f) - kwargs['stderr'] = log_file_f - kwargs['stdout'] = log_file_f - p = subprocess.Popen(cmd, **kwargs) - with open(pid_file, 'w') as f: - f.write(str(p.pid)) - return p - - -def start_services_and_watch(s): - logging.info(time.ctime()) - logging.info('JumpServer version {}, more see https://www.jumpserver.org'.format( - __version__) - ) - - services_set = parse_service(s) - for i in services_set: - if is_running(i): - show_service_status(i) - continue - p = start_service(i) - time.sleep(2) - processes[i] = p - - if not DAEMON: - watch_services() - else: - show_service_status(s) - context = get_daemon_context() - with context: - watch_services() - - -def get_daemon_context(): - daemon_pid_file = get_pid_file_path('jms') - daemon_log_f = open(get_log_file_path('jms'), 'a') - files_preserve.append(daemon_log_f) - context = daemon.DaemonContext( - pidfile=pidfile.TimeoutPIDLockFile(daemon_pid_file), - signal_map={ - signal.SIGTERM: lambda x, y: clean_up(), - signal.SIGHUP: 'terminate', - }, - stdout=daemon_log_f, - stderr=daemon_log_f, - files_preserve=files_preserve, - detach_process=True, - ) - return context - - -def stop_service(srv, sig=15): - services_set = parse_service(srv) - for s in services_set: - if not is_running(s): - show_service_status(s) - continue - print("Stop service: {}".format(s), end='') - pid = get_pid(s) - os.kill(pid, sig) - with LOCK: - process = processes.pop(s, None) - if process is None: - try: - process = psutil.Process(pid) - except: - pass - if process is None: - print("\033[31m No process found\033[0m") - continue - try: - process.wait(1) - except: - pass - for i in range(STOP_TIMEOUT): - if i == STOP_TIMEOUT - 1: - print("\033[31m Error\033[0m") - if not is_running(s): - print("\033[32m Ok\033[0m") - break - else: - time.sleep(1) - continue - - if srv == "all": - stop_daemon_service() - - -def stop_daemon_service(): - pid = get_pid('jms') - if pid and check_pid(pid): - os.kill(pid, 15) - - -def stop_multi_services(services): - for s in services: - stop_service(s, sig=9) - - -def stop_service_force(s): - stop_service(s, sig=9) - - -def clean_up(): - if not EXIT_EVENT.is_set(): - EXIT_EVENT.set() - processes_dump = {k: v for k, v in processes.items()} - for s1, p1 in processes_dump.items(): - stop_service(s1) - p1.wait() - - -def show_service_status(s): - services_set = parse_service(s) - for ns in services_set: - if is_running(ns): - pid = get_pid(ns) - print("{} is running: {}".format(ns, pid)) - else: - print("{} is stopped".format(ns)) - - def upgrade_db(): collect_static() perform_db_migrate() +def prepare(): + # installer(check) & k8s(no check) + check_database_connection() + check_migrations() + upgrade_db() + expire_caches() + + if __name__ == '__main__': parser = argparse.ArgumentParser( description=""" @@ -532,42 +125,43 @@ if __name__ == '__main__': ) parser.add_argument( 'action', type=str, - choices=("start", "stop", "restart", "status", "upgrade_db"), + choices=("start", "stop", "restart", "status", "upgrade_db", "collect_static"), help="Action to run" ) parser.add_argument( - "service", type=str, default="all", nargs="?", - choices=("all", "web", "task", "gunicorn", "celery", "beat", "celery,beat", "flower", "ws"), + "services", type=str, default='all', nargs="*", + choices=("all", "web", "task"), help="The service to start", ) - parser.add_argument('-d', '--daemon', nargs="?", const=1) - parser.add_argument('-w', '--worker', type=int, nargs="?", const=4) - parser.add_argument('-f', '--force', nargs="?", const=1) - args = parser.parse_args() - if args.daemon: - DAEMON = True + parser.add_argument('-d', '--daemon', nargs="?", const=True) + parser.add_argument('-w', '--worker', type=int, nargs="?", default=4) + parser.add_argument('-f', '--force', nargs="?", const=True) - if args.worker: - WORKERS = args.worker + args = parser.parse_args() action = args.action - srv = args.service - - if action == "start": - start_services_and_watch(srv) - os._exit(0) - elif action == "stop": - print("Stop service") - if args.force: - stop_service_force(srv) - else: - stop_service(srv) - elif action == "restart": - DAEMON = True - stop_service(srv) - time.sleep(5) - start_services_and_watch(srv) - elif action == "upgrade_db": + if action == "upgrade_db": upgrade_db() + elif action == "collect_static": + collect_static() else: - show_service_status(srv) + services = args.services if isinstance(args.services, list) else [args.services] + if action == 'start' and {'all', 'web'} & set(services): + prepare() + + services_string = ' '.join(services) + cmd = f'python manage.py {args.action} {services_string}' + if args.daemon: + cmd += ' --daemon' + if args.worker: + cmd += f' --worker {args.worker}' + if args.force: + cmd += ' --force' + apps_dir = os.path.join(BASE_DIR, 'apps') + + try: + # processes: main(3s) -> call(0.25s) -> service -> sub-process + code = subprocess.call(cmd, shell=True, cwd=apps_dir) + except KeyboardInterrupt: + time.sleep(2) + pass diff --git a/requirements/requirements.txt b/requirements/requirements.txt index d8e34784e..21be4262c 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,10 +1,10 @@ amqp==2.5.2 -ansible==2.8.8 +ansible==2.9.24 asn1crypto==0.24.0 bcrypt==3.1.4 billiard==3.6.3.0 -boto3==1.12.14 -botocore==1.15.26 +boto3==1.18.11 +botocore==1.21.11 celery==4.4.2 certifi==2018.1.18 cffi==1.13.2 @@ -14,7 +14,7 @@ coreapi==2.3.3 coreschema==0.0.4 cryptography==3.3.2 decorator==4.1.2 -Django==3.1.6 +Django==3.1.12 django-auth-ldap==2.2.0 django-bootstrap3==14.2.0 django-celery-beat==2.0 @@ -49,7 +49,7 @@ olefile==0.44 openapi-codec==1.3.2 paramiko==2.7.2 passlib==1.7.1 -Pillow==8.1.1 +Pillow==8.2.0 pyasn1==0.4.8 pycparser==2.19 pycryptodome==3.10.1 @@ -61,14 +61,14 @@ python-dateutil==2.6.1 pytz==2018.3 PyYAML==5.1 redis==3.5.3 -requests==2.22.0 -jms-storage==0.0.37 -s3transfer==0.3.3 +requests==2.25.1 +jms-storage==0.0.39 +s3transfer==0.5.0 simplejson==3.13.2 six==1.11.0 sshpubkeys==3.1.0 uritemplate==3.0.0 -urllib3==1.25.2 +urllib3==1.26.5 vine==1.3.0 drf-yasg==1.20.0 Werkzeug==0.15.3 @@ -113,4 +113,4 @@ termcolor==1.1.0 azure-identity==1.5.0 azure-mgmt-subscription==1.0.0 qingcloud-sdk==1.2.12 -django-simple-history==3.0.0 \ No newline at end of file +django-simple-history==3.0.0 diff --git a/utils/start_celery_beat.py b/utils/start_celery_beat.py index 466624e3e..34887fb4d 100644 --- a/utils/start_celery_beat.py +++ b/utils/start_celery_beat.py @@ -2,6 +2,7 @@ # import os import sys +import signal import subprocess import redis_lock @@ -28,7 +29,24 @@ cmd = [ '--max-interval', '60' ] -with redis_lock.Lock(redis, name="beat-distribute-start-lock", expire=60, auto_renewal=True): - print("Get beat lock start to run it") - code = subprocess.call(cmd, cwd=APPS_DIR) - sys.exit(code) +processes = [] + + +def stop_beat_process(sig, frame): + for p in processes: + os.kill(p.pid, 15) + + +def main(): + # 父进程结束通知子进程结束 + signal.signal(signal.SIGTERM, stop_beat_process) + + with redis_lock.Lock(redis, name="beat-distribute-start-lock", expire=60, auto_renewal=True): + print("Get beat lock start to run it") + process = subprocess.Popen(cmd, cwd=APPS_DIR) + processes.append(process) + process.wait() + + +if __name__ == '__main__': + main()