From 2562386fe039d99505614f64e878f22d497f0bff Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 22 Mar 2021 18:00:06 +0800 Subject: [PATCH 01/35] perf: delete_test_cookie --- apps/authentication/views/login.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 6b0e799b2..9f628e6e8 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -55,6 +55,9 @@ class UserLoginView(mixins.AuthMixin, FormView): def form_valid(self, form): if not self.request.session.test_cookie_worked(): return HttpResponse(_("Please enable cookies and try again.")) + # https://docs.djangoproject.com/en/3.1/topics/http/sessions/#setting-test-cookies + self.request.session.delete_test_cookie() + try: self.check_user_auth(decrypt_passwd=True) except errors.AuthFailedError as e: From 306f7a08d182de3e7048c819e2cac6a27de8a036 Mon Sep 17 00:00:00 2001 From: ibuler Date: Wed, 24 Mar 2021 10:10:38 +0800 Subject: [PATCH 02/35] =?UTF-8?q?perf:=20=E4=BF=AE=E6=94=B9=E8=A1=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E8=BF=81=E7=A7=BB=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?rdp=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0033_auto_20210324_1008.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/terminal/migrations/0033_auto_20210324_1008.py diff --git a/apps/terminal/migrations/0033_auto_20210324_1008.py b/apps/terminal/migrations/0033_auto_20210324_1008.py new file mode 100644 index 000000000..f5ecaf6d0 --- /dev/null +++ b/apps/terminal/migrations/0033_auto_20210324_1008.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2021-03-24 02:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0032_auto_20210302_1853'), + ] + + operations = [ + migrations.AlterField( + model_name='session', + name='login_from', + field=models.CharField(choices=[('ST', 'SSH Terminal'), ('RT', 'RDP Terminal'), ('WT', 'Web Terminal')], default='ST', max_length=2, verbose_name='Login from'), + ), + ] From c9a9ca79237d831002ff86950d185da92895f8fe Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 23 Mar 2021 18:36:25 +0800 Subject: [PATCH 03/35] =?UTF-8?q?fix:=20=E6=8E=88=E6=9D=83=E6=A0=91?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/perms/utils/asset/user_permission.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/perms/utils/asset/user_permission.py b/apps/perms/utils/asset/user_permission.py index 739a4191e..7f3a0941f 100644 --- a/apps/perms/utils/asset/user_permission.py +++ b/apps/perms/utils/asset/user_permission.py @@ -488,11 +488,12 @@ class UserGrantedAssetsQueryUtils(UserGrantedUtilsBase): if granted_status == NodeFrom.granted: assets = Asset.objects.order_by().filter(nodes__id=node.id) - return assets elif granted_status == NodeFrom.asset: - return self._get_indirect_granted_node_assets(node.id) + assets = self._get_indirect_granted_node_assets(node.id) else: - return Asset.objects.none() + assets = Asset.objects.none() + assets = assets.order_by('hostname') + return assets def _get_indirect_granted_node_assets(self, id) -> AssetQuerySet: assets = Asset.objects.order_by().filter(nodes__id=id).distinct() & self.get_direct_granted_assets() @@ -538,6 +539,10 @@ class UserGrantedAssetsQueryUtils(UserGrantedUtilsBase): class UserGrantedNodesQueryUtils(UserGrantedUtilsBase): + def sort(self, nodes): + nodes = sorted(nodes, key=lambda x: x.value) + return nodes + def get_node_children(self, key): if not key: return self.get_top_level_nodes() @@ -545,11 +550,13 @@ class UserGrantedNodesQueryUtils(UserGrantedUtilsBase): node = PermNode.objects.get(key=key) granted_status = node.get_granted_status(self.user) if granted_status == NodeFrom.granted: - return PermNode.objects.filter(parent_key=key) + nodes = PermNode.objects.filter(parent_key=key) elif granted_status in (NodeFrom.asset, NodeFrom.child): - return self.get_indirect_granted_node_children(key) + nodes = self.get_indirect_granted_node_children(key) else: - return PermNode.objects.none() + nodes = PermNode.objects.none() + nodes = self.sort(nodes) + return nodes def get_indirect_granted_node_children(self, key): """ @@ -571,7 +578,8 @@ class UserGrantedNodesQueryUtils(UserGrantedUtilsBase): def get_top_level_nodes(self): nodes = self.get_special_nodes() - nodes.extend(self.get_indirect_granted_node_children('')) + real_nodes = self.get_indirect_granted_node_children('') + nodes.extend(self.sort(real_nodes)) return nodes def get_ungrouped_node(self): From 85d226eb07663c63cf05dc884b8392883b772ec9 Mon Sep 17 00:00:00 2001 From: Bai Date: Fri, 26 Mar 2021 10:37:18 +0800 Subject: [PATCH 04/35] =?UTF-8?q?perf:=20Session=20Login=20from=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20RDP=20Terminal=20=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/models/session.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/terminal/models/session.py b/apps/terminal/models/session.py index 8c759a74d..ee7e07a4d 100644 --- a/apps/terminal/models/session.py +++ b/apps/terminal/models/session.py @@ -20,6 +20,7 @@ from .terminal import Terminal class Session(OrgModelMixin): class LOGIN_FROM(ChoiceSet): ST = 'ST', 'SSH Terminal' + RT = 'RT', 'RDP Terminal' WT = 'WT', 'Web Terminal' class PROTOCOL(ChoiceSet): From 07bd44990b11fb1c38c00cb2de0348a8cbed75f5 Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 29 Mar 2021 12:01:24 +0800 Subject: [PATCH 05/35] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20es=20?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=20https=20=E8=AF=81=E4=B9=A6=E9=AA=8C?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/backends/command/es.py | 4 ++++ apps/terminal/serializers/storage.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/terminal/backends/command/es.py b/apps/terminal/backends/command/es.py index 009435a5b..fc0f247f4 100644 --- a/apps/terminal/backends/command/es.py +++ b/apps/terminal/backends/command/es.py @@ -25,6 +25,10 @@ class CommandStore(): kwargs = config.get("OTHER", {}) self.index = config.get("INDEX") or 'jumpserver' self.doc_type = config.get("DOC_TYPE") or 'command_store' + + ignore_verify_certs = kwargs.pop('ignore_verify_certs', False) + if ignore_verify_certs: + kwargs['verify_certs'] = None self.es = Elasticsearch(hosts=hosts, max_retries=0, **kwargs) @staticmethod diff --git a/apps/terminal/serializers/storage.py b/apps/terminal/serializers/storage.py index 7cc0628ba..7f5dec2fe 100644 --- a/apps/terminal/serializers/storage.py +++ b/apps/terminal/serializers/storage.py @@ -181,7 +181,10 @@ class CommandStorageTypeESSerializer(serializers.Serializer): max_length=1024, default='jumpserver', label=_('Index'), allow_null=True ) DOC_TYPE = ReadableHiddenField(default='command', label=_('Doc type'), allow_null=True) - + ignore_verify_certs = serializers.BooleanField( + default=False, label=_('Ignore Certificate Verification'), + source='OTHER.ignore_verify_certs', allow_null=True, + ) # mapping From 7d5a13de389372814948d436e7b749efbfe2a372 Mon Sep 17 00:00:00 2001 From: ibuler Date: Fri, 26 Mar 2021 19:09:34 +0800 Subject: [PATCH 06/35] =?UTF-8?q?perf:=20=E4=BF=AE=E6=94=B9terminal=20stat?= =?UTF-8?q?uts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perf: 优化status api perf: 优化 status api perf: 修改sesion参数 perf: 修改migrations perf: 优化数据结构 perf: 修改保留日志 perf: 优化之前的一个写法 --- apps/terminal/api/__init__.py | 2 +- apps/terminal/api/component.py | 34 --- apps/terminal/api/status.py | 74 +++++++ apps/terminal/api/terminal.py | 48 +---- apps/terminal/const.py | 1 + .../migrations/0033_auto_20210329_1711.py | 43 ++++ apps/terminal/models/session.py | 3 +- apps/terminal/models/status.py | 52 ++++- apps/terminal/models/terminal.py | 199 ++++++------------ apps/terminal/serializers/__init__.py | 1 - apps/terminal/serializers/components.py | 25 --- apps/terminal/serializers/terminal.py | 35 ++- apps/terminal/tasks.py | 2 +- apps/terminal/urls/api_urls.py | 1 - apps/terminal/utils.py | 166 +++++++++++---- 15 files changed, 380 insertions(+), 306 deletions(-) delete mode 100644 apps/terminal/api/component.py create mode 100644 apps/terminal/api/status.py create mode 100644 apps/terminal/migrations/0033_auto_20210329_1711.py delete mode 100644 apps/terminal/serializers/components.py diff --git a/apps/terminal/api/__init__.py b/apps/terminal/api/__init__.py index c0c6b8197..e6a3b3885 100644 --- a/apps/terminal/api/__init__.py +++ b/apps/terminal/api/__init__.py @@ -5,4 +5,4 @@ from .session import * from .command import * from .task import * from .storage import * -from .component import * +from .status import * diff --git a/apps/terminal/api/component.py b/apps/terminal/api/component.py deleted file mode 100644 index f881b5e98..000000000 --- a/apps/terminal/api/component.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -# - -import logging -from rest_framework import generics, status -from rest_framework.views import Response - -from .. import serializers -from ..utils import ComponentsMetricsUtil -from common.permissions import IsAppUser, IsSuperUser - -logger = logging.getLogger(__file__) - - -__all__ = [ - 'ComponentsStateAPIView', 'ComponentsMetricsAPIView', -] - - -class ComponentsStateAPIView(generics.CreateAPIView): - """ koko, guacamole, omnidb 上报状态 """ - permission_classes = (IsAppUser,) - serializer_class = serializers.ComponentsStateSerializer - - -class ComponentsMetricsAPIView(generics.GenericAPIView): - """ 返回汇总组件指标数据 """ - permission_classes = (IsSuperUser,) - - def get(self, request, *args, **kwargs): - tp = request.query_params.get('type') - util = ComponentsMetricsUtil() - metrics = util.get_metrics(tp) - return Response(metrics, status=status.HTTP_200_OK) diff --git a/apps/terminal/api/status.py b/apps/terminal/api/status.py new file mode 100644 index 000000000..b39e13ba7 --- /dev/null +++ b/apps/terminal/api/status.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# + +import logging +from django.shortcuts import get_object_or_404 +from rest_framework import viewsets, generics +from rest_framework.views import Response +from rest_framework import status + +from common.permissions import IsAppUser, IsOrgAdminOrAppUser, IsSuperUser +from ..models import Terminal, Status, Session +from .. import serializers +from ..utils import TypedComponentsStatusMetricsUtil + +logger = logging.getLogger(__file__) + + +__all__ = [ + 'StatusViewSet', + 'ComponentsMetricsAPIView', +] + + +class StatusViewSet(viewsets.ModelViewSet): + queryset = Status.objects.all() + serializer_class = serializers.StatusSerializer + permission_classes = (IsOrgAdminOrAppUser,) + session_serializer_class = serializers.SessionSerializer + task_serializer_class = serializers.TaskSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.handle_sessions() + self.perform_create(serializer) + tasks = self.request.user.terminal.task_set.filter(is_finished=False) + serializer = self.task_serializer_class(tasks, many=True) + return Response(serializer.data, status=201) + + def handle_sessions(self): + session_ids = self.request.data.get('sessions', []) + # guacamole 上报的 session 是字符串 + # "[53cd3e47-210f-41d8-b3c6-a184f3, 53cd3e47-210f-41d8-b3c6-a184f4]" + if isinstance(session_ids, str): + session_ids = session_ids[1:-1].split(',') + session_ids = [sid.strip() for sid in session_ids if sid.strip()] + Session.set_sessions_active(session_ids) + + def get_queryset(self): + terminal_id = self.kwargs.get("terminal", None) + if terminal_id: + terminal = get_object_or_404(Terminal, id=terminal_id) + return terminal.status_set.all() + return super().get_queryset() + + def perform_create(self, serializer): + serializer.validated_data.pop('sessions', None) + serializer.validated_data["terminal"] = self.request.user.terminal + return super().perform_create(serializer) + + def get_permissions(self): + if self.action == "create": + self.permission_classes = (IsAppUser,) + return super().get_permissions() + + +class ComponentsMetricsAPIView(generics.GenericAPIView): + """ 返回汇总组件指标数据 """ + permission_classes = (IsSuperUser,) + + def get(self, request, *args, **kwargs): + util = TypedComponentsStatusMetricsUtil() + metrics = util.get_metrics() + return Response(metrics, status=status.HTTP_200_OK) diff --git a/apps/terminal/api/terminal.py b/apps/terminal/api/terminal.py index 91e9d4d07..5ee19b3e2 100644 --- a/apps/terminal/api/terminal.py +++ b/apps/terminal/api/terminal.py @@ -4,8 +4,7 @@ import logging import uuid from django.core.cache import cache -from django.shortcuts import get_object_or_404 -from rest_framework import viewsets, generics +from rest_framework import generics from rest_framework.views import APIView, Response from rest_framework import status from django.conf import settings @@ -13,13 +12,13 @@ from django.conf import settings from common.drf.api import JMSBulkModelViewSet from common.utils import get_object_or_none -from common.permissions import IsAppUser, IsOrgAdminOrAppUser, IsSuperUser, WithBootstrapToken -from ..models import Terminal, Status, Session +from common.permissions import IsAppUser, IsSuperUser, WithBootstrapToken +from ..models import Terminal from .. import serializers from .. import exceptions __all__ = [ - 'TerminalViewSet', 'StatusViewSet', 'TerminalConfig', + 'TerminalViewSet', 'TerminalConfig', 'TerminalRegistrationApi', ] logger = logging.getLogger(__file__) @@ -72,45 +71,6 @@ class TerminalViewSet(JMSBulkModelViewSet): return queryset -class StatusViewSet(viewsets.ModelViewSet): - queryset = Status.objects.all() - serializer_class = serializers.StatusSerializer - permission_classes = (IsOrgAdminOrAppUser,) - session_serializer_class = serializers.SessionSerializer - task_serializer_class = serializers.TaskSerializer - - def create(self, request, *args, **kwargs): - self.handle_sessions() - tasks = self.request.user.terminal.task_set.filter(is_finished=False) - serializer = self.task_serializer_class(tasks, many=True) - return Response(serializer.data, status=201) - - def handle_sessions(self): - session_ids = self.request.data.get('sessions', []) - # guacamole 上报的 session 是字符串 - # "[53cd3e47-210f-41d8-b3c6-a184f3, 53cd3e47-210f-41d8-b3c6-a184f4]" - if isinstance(session_ids, str): - session_ids = session_ids[1:-1].split(',') - session_ids = [sid.strip() for sid in session_ids if sid.strip()] - Session.set_sessions_active(session_ids) - - def get_queryset(self): - terminal_id = self.kwargs.get("terminal", None) - if terminal_id: - terminal = get_object_or_404(Terminal, id=terminal_id) - self.queryset = terminal.status_set.all() - return self.queryset - - def perform_create(self, serializer): - serializer.validated_data["terminal"] = self.request.user.terminal - return super().perform_create(serializer) - - def get_permissions(self): - if self.action == "create": - self.permission_classes = (IsAppUser,) - return super().get_permissions() - - class TerminalConfig(APIView): permission_classes = (IsAppUser,) diff --git a/apps/terminal/const.py b/apps/terminal/const.py index 6e3a38027..830913e28 100644 --- a/apps/terminal/const.py +++ b/apps/terminal/const.py @@ -31,6 +31,7 @@ class ComponentStatusChoices(TextChoices): critical = 'critical', _('Critical') high = 'high', _('High') normal = 'normal', _('Normal') + offline = 'offline', _('Offline') @classmethod def status(cls): diff --git a/apps/terminal/migrations/0033_auto_20210329_1711.py b/apps/terminal/migrations/0033_auto_20210329_1711.py new file mode 100644 index 000000000..bbc45a8c7 --- /dev/null +++ b/apps/terminal/migrations/0033_auto_20210329_1711.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1 on 2021-03-29 09:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terminal', '0032_auto_20210302_1853'), + ] + + operations = [ + migrations.RenameField( + model_name='status', + old_name='cpu_used', + new_name='cpu_load', + ), + migrations.AlterField( + model_name='status', + name='cpu_load', + field=models.FloatField(default=0, verbose_name='CPU Load'), + ), + migrations.AddField( + model_name='status', + name='disk_used', + field=models.FloatField(default=0, verbose_name='Disk Used'), + ), + migrations.AlterField( + model_name='status', + name='boot_time', + field=models.FloatField(default=0, verbose_name='Boot Time'), + ), + migrations.AlterField( + model_name='status', + name='connections', + field=models.IntegerField(default=0, verbose_name='Connections'), + ), + migrations.AlterField( + model_name='status', + name='threads', + field=models.IntegerField(default=0, verbose_name='Threads'), + ), + ] diff --git a/apps/terminal/models/session.py b/apps/terminal/models/session.py index ee7e07a4d..89e338143 100644 --- a/apps/terminal/models/session.py +++ b/apps/terminal/models/session.py @@ -14,7 +14,6 @@ from assets.models import Asset from orgs.mixins.models import OrgModelMixin from common.db.models import ChoiceSet from ..backends import get_multi_command_storage -from .terminal import Terminal class Session(OrgModelMixin): @@ -47,7 +46,7 @@ class Session(OrgModelMixin): is_finished = models.BooleanField(default=False, db_index=True) has_replay = models.BooleanField(default=False, verbose_name=_("Replay")) has_command = models.BooleanField(default=False, verbose_name=_("Command")) - terminal = models.ForeignKey(Terminal, null=True, on_delete=models.DO_NOTHING, db_constraint=False) + terminal = models.ForeignKey('terminal.Terminal', null=True, on_delete=models.DO_NOTHING, db_constraint=False) protocol = models.CharField(choices=PROTOCOL.choices, default='ssh', max_length=16, db_index=True) date_start = models.DateTimeField(verbose_name=_("Date start"), db_index=True, default=timezone.now) date_end = models.DateTimeField(verbose_name=_("Date end"), null=True) diff --git a/apps/terminal/models/status.py b/apps/terminal/models/status.py index a0607e5dc..dddf8f350 100644 --- a/apps/terminal/models/status.py +++ b/apps/terminal/models/status.py @@ -3,26 +3,62 @@ from __future__ import unicode_literals import uuid from django.db import models +from django.forms.models import model_to_dict +from django.core.cache import cache from django.utils.translation import ugettext_lazy as _ -from .terminal import Terminal +from common.utils import get_logger + + +logger = get_logger(__name__) class Status(models.Model): id = models.UUIDField(default=uuid.uuid4, primary_key=True) session_online = models.IntegerField(verbose_name=_("Session Online"), default=0) - cpu_used = models.FloatField(verbose_name=_("CPU Usage")) + cpu_load = models.FloatField(verbose_name=_("CPU Load"), default=0) memory_used = models.FloatField(verbose_name=_("Memory Used")) - connections = models.IntegerField(verbose_name=_("Connections")) - threads = models.IntegerField(verbose_name=_("Threads")) - boot_time = models.FloatField(verbose_name=_("Boot Time")) - terminal = models.ForeignKey(Terminal, null=True, on_delete=models.CASCADE) + disk_used = models.FloatField(verbose_name=_("Disk Used"), default=0) + connections = models.IntegerField(verbose_name=_("Connections"), default=0) + threads = models.IntegerField(verbose_name=_("Threads"), default=0) + boot_time = models.FloatField(verbose_name=_("Boot Time"), default=0) + terminal = models.ForeignKey('terminal.Terminal', null=True, on_delete=models.CASCADE) date_created = models.DateTimeField(auto_now_add=True) + CACHE_KEY = 'TERMINAL_STATUS_{}' + class Meta: db_table = 'terminal_status' get_latest_by = 'date_created' - def __str__(self): - return self.date_created.strftime("%Y-%m-%d %H:%M:%S") + def save_to_cache(self): + if not self.terminal: + return + key = self.CACHE_KEY.format(self.terminal.id) + data = model_to_dict(self) + cache.set(key, data, 60*3) + return data + + @classmethod + def get_terminal_latest_status(cls, terminal): + from ..utils import ComputeStatUtil + stat = cls.get_terminal_latest_stat(terminal) + return ComputeStatUtil.compute_component_status(stat) + + @classmethod + def get_terminal_latest_stat(cls, terminal): + key = cls.CACHE_KEY.format(terminal.id) + data = cache.get(key) + if not data: + return None + data.pop('terminal', None) + stat = cls(**data) + stat.terminal = terminal + return stat + + def save(self, force_insert=False, force_update=False, using=None, + update_fields=None): + self.terminal.set_alive(ttl=120) + return self.save_to_cache() + # return super().save() diff --git a/apps/terminal/models/terminal.py b/apps/terminal/models/terminal.py index 48e225cfd..e13902251 100644 --- a/apps/terminal/models/terminal.py +++ b/apps/terminal/models/terminal.py @@ -1,168 +1,63 @@ -from __future__ import unicode_literals import uuid from django.db import models +from django.core.cache import cache from django.utils.translation import ugettext_lazy as _ from django.conf import settings -from django.core.cache import cache from common.utils import get_logger from users.models import User +from .status import Status from .. import const +from ..const import ComponentStatusChoices as StatusChoice +from .session import Session logger = get_logger(__file__) -class ComputeStatusMixin: - - # system status - @staticmethod - def _common_compute_system_status(value, thresholds): - if thresholds[0] <= value <= thresholds[1]: - return const.ComponentStatusChoices.normal.value - elif thresholds[1] < value <= thresholds[2]: - return const.ComponentStatusChoices.high.value - else: - return const.ComponentStatusChoices.critical.value - - def _compute_system_cpu_load_1_status(self, value): - thresholds = [0, 5, 20] - return self._common_compute_system_status(value, thresholds) - - def _compute_system_memory_used_percent_status(self, value): - thresholds = [0, 85, 95] - return self._common_compute_system_status(value, thresholds) - - def _compute_system_disk_used_percent_status(self, value): - thresholds = [0, 80, 99] - return self._common_compute_system_status(value, thresholds) - - def _compute_system_status(self, state): - system_status_keys = [ - 'system_cpu_load_1', 'system_memory_used_percent', 'system_disk_used_percent' - ] - system_status = [] - for system_status_key in system_status_keys: - state_value = state.get(system_status_key) - if state_value is None: - msg = 'state: {}, state_key: {}, state_value: {}' - logger.debug(msg.format(state, system_status_key, state_value)) - state_value = 0 - status = getattr(self, f'_compute_{system_status_key}_status')(state_value) - system_status.append(status) - return system_status - - def _compute_component_status(self, state): - system_status = self._compute_system_status(state) - if const.ComponentStatusChoices.critical in system_status: - return const.ComponentStatusChoices.critical - elif const.ComponentStatusChoices.high in system_status: - return const.ComponentStatusChoices.high - else: - return const.ComponentStatusChoices.normal - - @staticmethod - def _compute_component_status_display(status): - return getattr(const.ComponentStatusChoices, status).label - - -class TerminalStateMixin(ComputeStatusMixin): - CACHE_KEY_COMPONENT_STATE = 'CACHE_KEY_COMPONENT_STATE_TERMINAL_{}' - CACHE_TIMEOUT = 120 +class TerminalStatusMixin: + ALIVE_KEY = 'TERMINAL_ALIVE_{}' + id: str @property - def cache_key(self): - return self.CACHE_KEY_COMPONENT_STATE.format(str(self.id)) - - # get - def _get_from_cache(self): - return cache.get(self.cache_key) - - def _set_to_cache(self, state): - cache.set(self.cache_key, state, self.CACHE_TIMEOUT) - - # set - def _add_status(self, state): - status = self._compute_component_status(state) - status_display = self._compute_component_status_display(status) - state.update({ - 'status': status, - 'status_display': status_display - }) + def latest_status(self): + return Status.get_terminal_latest_status(self) @property - def state(self): - state = self._get_from_cache() - return state or {} - - @state.setter - def state(self, state): - self._add_status(state) - self._set_to_cache(state) - - -class TerminalStatusMixin(TerminalStateMixin): - - # alive - @property - def is_alive(self): - return bool(self.state) - - # status - @property - def status(self): - if self.is_alive: - return self.state['status'] - else: - return const.ComponentStatusChoices.critical.value + def latest_status_display(self): + return self.latest_status.label @property - def status_display(self): - return self._compute_component_status_display(self.status) + def latest_stat(self): + return Status.get_terminal_latest_stat(self) @property def is_normal(self): - return self.status == const.ComponentStatusChoices.normal.value + return self.latest_status == StatusChoice.normal @property def is_high(self): - return self.status == const.ComponentStatusChoices.high.value + return self.latest_status == StatusChoice.high @property def is_critical(self): - return self.status == const.ComponentStatusChoices.critical.value - - -class Terminal(TerminalStatusMixin, models.Model): - id = models.UUIDField(default=uuid.uuid4, primary_key=True) - name = models.CharField(max_length=128, verbose_name=_('Name')) - type = models.CharField( - choices=const.TerminalTypeChoices.choices, default=const.TerminalTypeChoices.koko.value, - max_length=64, verbose_name=_('type') - ) - remote_addr = models.CharField(max_length=128, blank=True, verbose_name=_('Remote Address')) - ssh_port = models.IntegerField(verbose_name=_('SSH Port'), default=2222) - http_port = models.IntegerField(verbose_name=_('HTTP Port'), default=5000) - command_storage = models.CharField(max_length=128, verbose_name=_("Command storage"), default='default') - replay_storage = models.CharField(max_length=128, verbose_name=_("Replay storage"), default='default') - user = models.OneToOneField(User, related_name='terminal', verbose_name='Application User', null=True, on_delete=models.CASCADE) - is_accepted = models.BooleanField(default=False, verbose_name='Is Accepted') - is_deleted = models.BooleanField(default=False) - date_created = models.DateTimeField(auto_now_add=True) - comment = models.TextField(blank=True, verbose_name=_('Comment')) + return self.latest_status == StatusChoice.critical @property - def is_active(self): - if self.user and self.user.is_active: - return True - return False + def is_alive(self): + key = self.ALIVE_KEY.format(self.id) + # return self.latest_status != StatusChoice.offline + return cache.get(key, False) - @is_active.setter - def is_active(self, active): - if self.user: - self.user.is_active = active - self.user.save() + def set_alive(self, ttl=120): + key = self.ALIVE_KEY.format(self.id) + cache.set(key, True, ttl) + + +class StorageMixin: + command_storage: str + replay_storage: str def get_command_storage(self): from .storage import CommandStorage @@ -198,6 +93,44 @@ class Terminal(TerminalStatusMixin, models.Model): config = self.get_replay_storage_config() return {"TERMINAL_REPLAY_STORAGE": config} + +class Terminal(StorageMixin, TerminalStatusMixin, models.Model): + id = models.UUIDField(default=uuid.uuid4, primary_key=True) + name = models.CharField(max_length=128, verbose_name=_('Name')) + type = models.CharField( + choices=const.TerminalTypeChoices.choices, default=const.TerminalTypeChoices.koko.value, + max_length=64, verbose_name=_('type') + ) + remote_addr = models.CharField(max_length=128, blank=True, verbose_name=_('Remote Address')) + ssh_port = models.IntegerField(verbose_name=_('SSH Port'), default=2222) + http_port = models.IntegerField(verbose_name=_('HTTP Port'), default=5000) + command_storage = models.CharField(max_length=128, verbose_name=_("Command storage"), default='default') + replay_storage = models.CharField(max_length=128, verbose_name=_("Replay storage"), default='default') + user = models.OneToOneField(User, related_name='terminal', verbose_name='Application User', null=True, on_delete=models.CASCADE) + is_accepted = models.BooleanField(default=False, verbose_name='Is Accepted') + is_deleted = models.BooleanField(default=False) + date_created = models.DateTimeField(auto_now_add=True) + comment = models.TextField(blank=True, verbose_name=_('Comment')) + + + @property + def is_active(self): + if self.user and self.user.is_active: + return True + return False + + @is_active.setter + def is_active(self, active): + if self.user: + self.user.is_active = active + self.user.save() + + def get_online_sessions(self): + return Session.objects.filter(terminal=self, is_finished=False) + + def get_online_session_count(self): + return self.get_online_sessions().count() + @staticmethod def get_login_title_setting(): login_title = None diff --git a/apps/terminal/serializers/__init__.py b/apps/terminal/serializers/__init__.py index e958d7955..f1714dc21 100644 --- a/apps/terminal/serializers/__init__.py +++ b/apps/terminal/serializers/__init__.py @@ -4,4 +4,3 @@ from .terminal import * from .session import * from .storage import * from .command import * -from .components import * diff --git a/apps/terminal/serializers/components.py b/apps/terminal/serializers/components.py deleted file mode 100644 index d6e6d7f56..000000000 --- a/apps/terminal/serializers/components.py +++ /dev/null @@ -1,25 +0,0 @@ - -from rest_framework import serializers -from django.utils.translation import ugettext_lazy as _ - - -class ComponentsStateSerializer(serializers.Serializer): - # system - system_cpu_load_1 = serializers.FloatField( - required=False, label=_("System cpu load (1 minutes)") - ) - system_memory_used_percent = serializers.FloatField( - required=False, label=_('System memory used percent') - ) - system_disk_used_percent = serializers.FloatField( - required=False, label=_('System disk used percent') - ) - # sessions - session_active_count = serializers.IntegerField( - required=False, label=_("Session active count") - ) - - def save(self, **kwargs): - request = self.context['request'] - terminal = request.user.terminal - terminal.state = self.validated_data diff --git a/apps/terminal/serializers/terminal.py b/apps/terminal/serializers/terminal.py index caffba522..765634393 100644 --- a/apps/terminal/serializers/terminal.py +++ b/apps/terminal/serializers/terminal.py @@ -9,15 +9,34 @@ from common.utils import get_request_ip from ..models import ( Terminal, Status, Session, Task, CommandStorage, ReplayStorage ) -from .components import ComponentsStateSerializer + + +class StatusSerializer(serializers.ModelSerializer): + sessions = serializers.ListSerializer( + child=serializers.CharField(max_length=35), write_only=True + ) + + class Meta: + fields = [ + 'id', + 'cpu_load', 'memory_used', 'disk_used', + 'session_online', 'sessions', + 'terminal', 'date_created', + ] + extra_kwargs = { + "cpu_load": {'default': 0}, + "memory_used": {'default': 0}, + "disk_used": {'default': 0}, + } + model = Status class TerminalSerializer(BulkModelSerializer): session_online = serializers.SerializerMethodField() is_alive = serializers.BooleanField(read_only=True) - status = serializers.CharField(read_only=True) - status_display = serializers.CharField(read_only=True) - state = ComponentsStateSerializer(read_only=True) + status = serializers.CharField(read_only=True, source='latest_status') + status_display = serializers.CharField(read_only=True, source='latest_status_display') + stat = StatusSerializer(read_only=True, source='latest_stat') class Meta: model = Terminal @@ -25,7 +44,7 @@ class TerminalSerializer(BulkModelSerializer): 'id', 'name', 'type', 'remote_addr', 'http_port', 'ssh_port', 'comment', 'is_accepted', "is_active", 'session_online', 'is_alive', 'date_created', 'command_storage', 'replay_storage', - 'status', 'status_display', 'state' + 'status', 'status_display', 'stat' ] read_only_fields = ['type', 'date_created'] @@ -59,12 +78,6 @@ class TerminalSerializer(BulkModelSerializer): return Session.objects.filter(terminal=obj, is_finished=False).count() -class StatusSerializer(serializers.ModelSerializer): - class Meta: - fields = ['id', 'terminal'] - model = Status - - class TaskSerializer(BulkModelSerializer): class Meta: fields = '__all__' diff --git a/apps/terminal/tasks.py b/apps/terminal/tasks.py index b743f10f9..701aeac96 100644 --- a/apps/terminal/tasks.py +++ b/apps/terminal/tasks.py @@ -29,7 +29,7 @@ logger = get_task_logger(__name__) @after_app_ready_start @after_app_shutdown_clean_periodic def delete_terminal_status_period(): - yesterday = timezone.now() - datetime.timedelta(days=1) + yesterday = timezone.now() - datetime.timedelta(days=7) Status.objects.filter(date_created__lt=yesterday).delete() diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index 38b6df976..57fb6eb73 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -35,7 +35,6 @@ urlpatterns = [ path('command-storages//test-connective/', api.CommandStorageTestConnectiveApi.as_view(), name='command-storage-test-connective'), # components path('components/metrics/', api.ComponentsMetricsAPIView.as_view(), name='components-metrics'), - path('components/state/', api.ComponentsStateAPIView.as_view(), name='components-state'), # v2: get session's replay # path('v2/sessions//replay/', # api.SessionReplayV2ViewSet.as_view({'get': 'retrieve'}), diff --git a/apps/terminal/utils.py b/apps/terminal/utils.py index 8ceff0166..b13383fba 100644 --- a/apps/terminal/utils.py +++ b/apps/terminal/utils.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # import os +from itertools import groupby from django.conf import settings from django.core.files.storage import default_storage @@ -10,9 +11,7 @@ import jms_storage from common.tasks import send_mail_async from common.utils import get_logger, reverse -from settings.models import Setting from . import const - from .models import ReplayStorage, Session, Command logger = get_logger(__name__) @@ -141,23 +140,73 @@ def send_command_execution_alert_mail(command): send_mail_async.delay(subject, message, recipient_list, html_message=message) -class ComponentsMetricsUtil(object): - +class ComputeStatUtil: + # system status @staticmethod - def get_components(tp=None): + def _common_compute_system_status(value, thresholds): + if thresholds[0] <= value <= thresholds[1]: + return const.ComponentStatusChoices.normal.value + elif thresholds[1] < value <= thresholds[2]: + return const.ComponentStatusChoices.high.value + else: + return const.ComponentStatusChoices.critical.value + + @classmethod + def _compute_system_stat_status(cls, stat): + system_stat_thresholds_mapper = { + 'cpu_load': [0, 5, 20], + 'memory_used': [0, 85, 95], + 'disk_used': [0, 80, 99] + } + system_status = {} + for stat_key, thresholds in system_stat_thresholds_mapper.items(): + stat_value = getattr(stat, stat_key) + if stat_value is None: + msg = 'stat: {}, stat_key: {}, stat_value: {}' + logger.debug(msg.format(stat, stat_key, stat_value)) + stat_value = 0 + status = cls._common_compute_system_status(stat_value, thresholds) + system_status[stat_key] = status + return system_status + + @classmethod + def compute_component_status(cls, stat): + if not stat: + return const.ComponentStatusChoices.offline + system_status_values = cls._compute_system_stat_status(stat).values() + if const.ComponentStatusChoices.critical in system_status_values: + return const.ComponentStatusChoices.critical + elif const.ComponentStatusChoices.high in system_status_values: + return const.ComponentStatusChoices.high + else: + return const.ComponentStatusChoices.normal + + +class TypedComponentsStatusMetricsUtil(object): + def __init__(self): + self.components = [] + self.grouped_components = [] + self.get_components() + + def get_components(self): from .models import Terminal components = Terminal.objects.filter(is_deleted=False).order_by('type') - if tp: - components = components.filter(type=tp) - return components + grouped_components = groupby(components, lambda c: c.type) + grouped_components = [(i[0], list(i[1])) for i in grouped_components] + self.grouped_components = grouped_components + self.components = components - def get_metrics(self, tp=None): - components = self.get_components(tp) - total_count = normal_count = high_count = critical_count = offline_count = \ - session_active_total = 0 - for component in components: - total_count += 1 - if component.is_alive: + def get_metrics(self): + metrics = [] + for _tp, components in self.grouped_components: + normal_count = high_count = critical_count = 0 + total_count = offline_count = session_online_total = 0 + + for component in components: + total_count += 1 + if not component.is_alive: + offline_count += 1 + continue if component.is_normal: normal_count += 1 elif component.is_high: @@ -165,20 +214,23 @@ class ComponentsMetricsUtil(object): else: # critical critical_count += 1 - session_active_total += component.state.get('session_active_count', 0) - else: - offline_count += 1 - return { - 'total': total_count, - 'normal': normal_count, - 'high': high_count, - 'critical': critical_count, - 'offline': offline_count, - 'session_active': session_active_total - } + session_online_total += component.get_online_session_count() + metrics.append({ + 'total': total_count, + 'normal': normal_count, + 'high': high_count, + 'critical': critical_count, + 'offline': offline_count, + 'session_active': session_online_total, + 'type': _tp, + }) + return metrics -class ComponentsPrometheusMetricsUtil(ComponentsMetricsUtil): +class ComponentsPrometheusMetricsUtil(TypedComponentsStatusMetricsUtil): + def __init__(self): + super().__init__() + self.metrics = self.get_metrics() @staticmethod def convert_status_metrics(metrics): @@ -190,50 +242,74 @@ class ComponentsPrometheusMetricsUtil(ComponentsMetricsUtil): 'offline': metrics['offline'] } - def get_prometheus_metrics_text(self): + def get_component_status_metrics(self): prometheus_metrics = list() - # 各组件状态个数汇总 prometheus_metrics.append('# JumpServer 各组件状态个数汇总') status_metric_text = 'jumpserver_components_status_total{component_type="%s", status="%s"} %s' - for tp in const.TerminalTypeChoices.types(): + for metric in self.metrics: + tp = metric['type'] prometheus_metrics.append(f'## 组件: {tp}') - metrics_tp = self.get_metrics(tp) - status_metrics = self.convert_status_metrics(metrics_tp) + status_metrics = self.convert_status_metrics(metric) for status, value in status_metrics.items(): metric_text = status_metric_text % (tp, status, value) prometheus_metrics.append(metric_text) + return prometheus_metrics - prometheus_metrics.append('\n') - + def get_component_session_metrics(self): + prometheus_metrics = list() # 各组件在线会话数汇总 prometheus_metrics.append('# JumpServer 各组件在线会话数汇总') session_active_metric_text = 'jumpserver_components_session_active_total{component_type="%s"} %s' - for tp in const.TerminalTypeChoices.types(): + + for metric in self.metrics: + tp = metric['type'] prometheus_metrics.append(f'## 组件: {tp}') - metrics_tp = self.get_metrics(tp) - metric_text = session_active_metric_text % (tp, metrics_tp['session_active']) + metric_text = session_active_metric_text % (tp, metric['session_active']) prometheus_metrics.append(metric_text) + return prometheus_metrics - prometheus_metrics.append('\n') - + def get_component_stat_metrics(self): + prometheus_metrics = list() # 各组件节点指标 prometheus_metrics.append('# JumpServer 各组件一些指标') state_metric_text = 'jumpserver_components_%s{component_type="%s", component="%s"} %s' - states = [ + stats_key = [ + 'cpu_load', 'memory_used', 'disk_used', 'session_online' + ] + old_stats_key = [ 'system_cpu_load_1', 'system_memory_used_percent', 'system_disk_used_percent', 'session_active_count' ] - for state in states: - prometheus_metrics.append(f'## 指标: {state}') - components = self.get_components() - for component in components: + old_stats_key_mapper = dict(zip(stats_key, old_stats_key)) + + for stat_key in stats_key: + prometheus_metrics.append(f'## 指标: {stat_key}') + for component in self.components: if not component.is_alive: continue + component_stat = component.latest_stat + if not component_stat: + continue metric_text = state_metric_text % ( - state, component.type, component.name, component.state.get(state) + stat_key, component.type, component.name, getattr(component_stat, stat_key) ) prometheus_metrics.append(metric_text) + old_stat_key = old_stats_key_mapper.get(stat_key) + old_metric_text = state_metric_text % ( + old_stat_key, component.type, component.name, getattr(component_stat, stat_key) + ) + prometheus_metrics.append(old_metric_text) + return prometheus_metrics + def get_prometheus_metrics_text(self): + prometheus_metrics = list() + for method in [ + self.get_component_status_metrics, + self.get_component_session_metrics, + self.get_component_stat_metrics + ]: + prometheus_metrics.extend(method()) + prometheus_metrics.append('\n') prometheus_metrics_text = '\n'.join(prometheus_metrics) return prometheus_metrics_text From 6b5435b768217b9a9482c3b25a0322681f6fce37 Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 30 Mar 2021 09:39:30 +0800 Subject: [PATCH 07/35] =?UTF-8?q?fix:=20Default=20=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E4=B8=8B=E5=87=BA=E7=8E=B0=20app=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/orgs/api.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/orgs/api.py b/apps/orgs/api.py index 4f72fc792..b241301a8 100644 --- a/apps/orgs/api.py +++ b/apps/orgs/api.py @@ -90,6 +90,11 @@ class OrgMemberRelationBulkViewSet(JMSBulkRelationModelViewSet): filterset_class = OrgMemberRelationFilterSet search_fields = ('user__name', 'user__username', 'org__name') + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.exclude(user__role=User.ROLE.APP) + return queryset + def perform_bulk_destroy(self, queryset): objs = list(queryset.all().prefetch_related('user', 'org')) queryset.delete() From 3e11249e8c81dc3bb42238f4514ec94827b0d169 Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 23 Mar 2021 19:45:52 +0800 Subject: [PATCH 08/35] perf: upgrade requirements version --- requirements/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index b25368599..9dbb87017 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -12,9 +12,9 @@ chardet==3.0.4 configparser==3.5.0 coreapi==2.3.3 coreschema==0.0.4 -cryptography==3.2 +cryptography==3.3.2 decorator==4.1.2 -Django==3.1 +Django==3.1.6 django-auth-ldap==2.2.0 django-bootstrap3==14.2.0 django-celery-beat==2.0 @@ -39,7 +39,7 @@ gunicorn==19.9.0 idna==2.6 itsdangerous==0.24 itypes==1.1.0 -Jinja2==2.10.1 +Jinja2==2.11.3 jmespath==0.9.3 kombu==4.6.8 ldap3==2.4 @@ -49,7 +49,7 @@ olefile==0.44 openapi-codec==1.3.2 paramiko==2.7.2 passlib==1.7.1 -Pillow==7.1.0 +Pillow==8.1.1 pyasn1==0.4.8 pycparser==2.19 pycryptodome==3.10.1 From 06ed358fbc70d2c5154f85478bfa0040e8b8d958 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 22 Mar 2021 13:56:40 +0800 Subject: [PATCH 09/35] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96acl=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/errors.py | 24 ++++++++++- apps/authentication/mixins.py | 2 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 73487 -> 73503 bytes apps/locale/zh/LC_MESSAGES/django.po | 57 ++++++++++++++------------- 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 68c85ff87..576a4e4b1 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -31,7 +31,7 @@ reason_choices = { reason_user_invalid: _('Disabled or expired'), reason_user_inactive: _("This account is inactive."), reason_backend_not_match: _("Auth backend not match"), - reason_acl_not_allow: _("ACL is not allowed") + reason_acl_not_allow: _("ACL is not allowed"), } old_reason_choices = { '0': '-', @@ -184,6 +184,28 @@ class MFARequiredError(NeedMoreInfoError): } +class ACLError(AuthFailedNeedLogMixin, AuthFailedError): + msg = reason_acl_not_allow + error = 'acl_error' + + def __init__(self, msg, **kwargs): + self.msg = msg + super().__init__(**kwargs) + + def as_data(self): + return { + "error": reason_acl_not_allow, + "msg": self.msg + } + + +class LoginIPNotAllowed(ACLError): + def __init__(self, username, request, **kwargs): + self.username = username + self.request = request + super().__init__(_("IP is not allowed"), **kwargs) + + class LoginConfirmBaseError(NeedMoreInfoError): def __init__(self, ticket_id, **kwargs): self.ticket_id = ticket_id diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 97adb0e67..747127ca9 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -183,7 +183,7 @@ class AuthMixin: from acls.models import LoginACL is_allowed = LoginACL.allow_user_to_login(user, ip) if not is_allowed: - raise self.raise_credential_error(error=errors.reason_acl_not_allow) + raise errors.LoginIPNotAllowed(username=user.username, request=self.request) def check_user_auth(self, decrypt_passwd=False): self.check_is_block() diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 0179f659e7018038f2b842897fbf87589eb4f5cf..b8d77bd5756a5c2893b9c951e17a430682c60d5e 100644 GIT binary patch delta 17249 zcmZA72Y8M5|NrrG5Hk@G5s^d?1hG>h)C`FkTh(gq8Z~Ma<)F1|-bSse)z+fR8^zptR$*F^PKAM;?R!5*I{ zghYQbzhAco;ZRL6gsFU@R2-24zT60=jUIOfE17AIL;cZkm|XiA1A z&<>NZ4{C<1P&40xCGl(2%CDn3zKg2&#LPC7)e{%M2uwgtxG8GF-B9h{#$X)kv%~}} zPsTjd7JZNE;3DS6rWzEQq;<`4R;5~gF2c5sD9!tzXqzm`lxoTEx!jAAbuNjF}`Oa z3C(zpRak~<_?g99QCqhgRqp^6!Bd!kPfSv_*TdD;`2MC^y1gX#>;%El@ks4z<;9p#~aej>W>nQ&Bs#4mE+zs2$yl`SB#` zDfE0~Gua4u@VCFUB`z*|xM??FxM zC)C6*qK@#^DE7Y*iJb4b4ceeO?u?pHH`EUFF^8c#9*^yC8tN#nVH7^Zx)?m#jay=E z;sL038&OZq4lIRxMzjCgirZvpz~`vD)icJuOG8jAERE`*l35+~_|!wS?}d5_hGQg7 z#S-`_#$X0k#Ji}SjA2~uOk*DjHGBg#@H?mu-$xyRW3E8e+k~3Xcb0z`b+#8Q|1N4F zk5Lo;+wud(y7@sEO@2O9xvvrlon3XyXn^XV1!|yns3Uk2mERB5a4;(WJ$wZxqt0{} zs=rI9qxl^*!56571&(u99)%p0&r^YfZi>OE0mh&Po`Bkc>8Pz*g4&^tsQUX*0~|)x zKaUZ374?|?jT$g?yn8Riq9$A$bu%`>AU*$WNhFfd9W~$*)J?d~+=v=z3+g8O*77f- zI=XK0J=6e?P&?px-+dnhp?0hss{ce(c?yOwzNZxl-IYC1Gw!Pb9EGYl9W&t~b14=e z{sh(W4pjYbP!m0gMe(W`@B!}(;*zMH?1|dxk?1Q#VjPLJ_zCLfXgtB)(%z_v4YGK+ z#Xc-g{se4>Td)EKOmw%t3Th{sq9*t{YUN!~6C8xvN#8{FUw8EqtN4Yv-Q0&d+k>d* z^#tnd|HgutZIXKgMN#=>Py;8T224iXoGq<<5^6$oFdi3AV*l0f2QsqY3Diw?24nFS zX2!tD9*=e^4{B@6n=MfT4#liE4z)(l_E~%wH9_Bb5_)4jKs5}S z>JAuzC5TI+8a6{UXpcE?5Ng0t7Jq;_iD#e|un;xj^{AV32j;{>=4oVtKF<{r;S@YZ zZDG(fcLMpa2yrax2F)Df+Du0M*6WDsU=V60lTb&s$jY~(j^vQVSIwuW_ekIjcfwJq36((Ak4Nob zUG!<7#+LCW1`_v1H5i7Ka1@ro|6xVEfO>v|X1Y&FY1DvesCJ!EEAEE={xB!;Fx2md zaj1U2n#ula3x6j=&-0(CEzUj5enw+3aRO$-WYpcCg6c30)&6zVKyRXU>TS%8V^Is7 zi8|s{sQ$L1>hGV${%giRk)iK^v#1X9%ytI~N8MC0sIyJ5xEA_vLezvhq6Y46`NPez zsDY-S7C0X@p-rfx*zY5u0WYFfbPd(Mz4$_yy{$PnzdZ3%HD0 zz-`pZpJHCjJJ*eiBjfozwMnR>j;L?7ai|F_!m_v-)$l6ns2-!X^aW~y+2*+e2BQWn zfI6xYsC%grYQ?Xj>ZhS5*cS8a`G13i?&e{b4;@s)&rub>w0I}#h`vYdz%i_Zmr(WI#`9SsE&K1w*Fnz#6CrJydKrzR;-AJ zQ1xG8ehmD`eNV)qen&J%<@ZNTd;;o-W?1=>kL>yXiVRI;A8Mcjs1=<+b#M_ikw>U| z;05ZY{1??O&q8;r!%-_MhUzELtYhVkEpCUJNY{nzzbXzSLt8%53dW;mJPWmD3sEaw zWAS#>zz58usG~Y<<+m_`_!(*;c^C0F1V*EFrU7apJ$)n!km!r`@B`Fi_#=knb<_l3 zqGp_Ju{+T)RL9Y%iIqehO(GV=Ca5Fog<-f5wU94R_4l9_=sQ9}D?fv}TQ6DsyH$9E z+WMDR0y8ghXI>gLvGS;nl2A8eU3>-8P&@R#)c@B~&MaBiJf&ok2 zmA#7UAQd&^G}PI4LT%kZi-)4>O-9w5Y4OLXg{?*nxWVH6Sd#b%cGvU&l!Q9$__4c1 zT~G}Np|*SkYT!wz6)Zxnd<$yeJ?4JY4rHK?;v8y1e_$lOL`@`OncHtAEXeqt+9cGl z1FAtU)Xg*uHQ;0n#g(Wt-)4S;ns^54=uV)H;u30tw=8~W<$s~tXaB@K(opniWf3H_ z(lV$Hs$m=^V`1!Teu)09#3=IjqbB&P`42`DM=WXG}0>V{hVn7=y2`bib}gU`OIjSQm4za_^xQsK;$GR>yRF7w=&Y zd~3Da?#ODNdsoMQ>dw3+7Nf#Itbp@y2=2#tto|8))nG5w;ADC(xFV)>0x_ee)9g#9gl zI;#E0sC!~P>fYLc>i>Jpf*F_teJ4naBk?mn#?I^90b8$ke>T5`b;zHAW$_s5$o|0; zjN8E91lS*|;*Y5JMJBdE6VHyiXQEI$RvGn5Z;F9>{=1XV%`gZx({ZR7PIoJK=Ahp7 z%TRB!jhGwvqdGi=D!*&~gIa0u7w!&(qgGlBb+42|l~2PEJ^u~~4ZIq4W}7f89x_j0 zPU3T@30y5T8P&;@C)$W+(|BPz?(0qYDRmk+E+b}n3WyMev zsf9t<2=&;s!+6|{74Z(LeN?)8BqcF~I1zO;4N>odE|`Em%!BEu{=Z9S|JC3q8S3Z? z=Eb|18$Fxd9SBA>j6toq0%{`FEbfWA$p)h8O+odu2(#k`)P%O0N3b&Sjm_-8&M<0= zd)HP+?n+M`tc7W)4rW;S0@S^+6m>MKP+PhgwScct6Fh)@@dWC9Qgf?2p>C+78HO5X zl+O~Au_hVwPy?Jqy%#QEB@El?vBNA1giZqtcK@MN1Jbl zd(`Do^?mh8lq1pJGNzzr_%TL#`O-lRwD&7_hpwSkcn8(tBh;1$?{s&hFoqIWKpkN{ zRDZ2d?}c`#Bk$~%`#b|l=&XFGj^PIfjDA*OE~XJL z#ss{BT3MkJ?t3C0b$2&H<#$F6&=u_tFFvz4 z*H7-sBF&0sDuz(6lf?tf3FcyqqViW=u{)B=2gC*4E@>L^N}DwIXN8EaVF z(c*qsjQnY+E#GM6`_0ql4J&_Q2A*>37sXK8Rdw}wnvl@l`zC4v{n5Xb<_y$CKC^f~ zYGs!!{>#jF+HGGJbtJE#7E;sVG%P~g4z==;SW<74NtUtE++`j_P3VlpznH(FCiDz7 zQ1}^l!bLDMaW&KcHBs#wqbA(a^81^k(Es~?GKqL9ti+;t6wBd5%!5VFx*b(Qb)00T zpeEAF%HOg45vY4=qUFysmzXP2{j5d*@BeL9a1gbEL%`nn#eG7f;rb*d5-vFuQS?3hThTNp$5K(%71PZJr~@S=0~k8)~sSSM78UPYS+{9 z-!{i$74jX_4jwdr^^x#DkEj_1{_Ms@Q5{t>lTZWHMs0Z`%YV({&ZwVMeJy_&mLMK& z<*QKRY(gzypXGZ`IuUV^{%?Wo7u~LdQA0(crclE2%bL|JKNYo>G*tawmOseicTrOv zZ}~GVo^P&1y?Zxapp{>7bR9IE4LsE+EP*3iP@E*AGU$Dr!Zz)tuP7Q`p0 zg@jyk%L}0TDT?a1(j`V$g<4i21q%^3w|D@m!3flfr=SjN1*+Y4R7ZQ95hY`bYkNJH z-6e#gN=l$wCRp6W*;g{uzw3i7XC$hxNvKC*25QVDsQmS)p3=>|sMlGBc@N7Hhg`9> zm?@}sP0jX}-|Gsyt^xX6!5DKI>IdIPn1o+j{u9(hGGBGgiRw7SEM&%GUGl41JPNfX zld%FWw)pT>pPO-s46QiNuWrYMun2K+)C5y4|20%colyf1viva?Pq26n#!$Y{@^_$a zi~Xo?u47mRAHPXLGb(z`eK=cT3F4`kA2*>6?Evbyub?LI)XaI^jiWJ={0gXnnxH1w z*4b3rJ>=2kXzxC-8q+MEhw5>O<$r-{xx?c9<}pk4SWLW+g)IitFN2nEKx$S=W=E4Z#C{(*Dm)de%Y>gVI1FpcKsE!N#=9ZT;E1R#H$ykSajj%C(i1Bz9>EGwc z^1Hhg;i#1rvpC+YiaMJ57I(rN#C=fphnS`Kc9FKX(pJ{$#`I}JnccaeyI0ob2m=gmZxH}V$daNs;cCHudJu~_N`(J^? zLd!Ua3B)%nF8I)GPzB@2Z-Qz##Nt_~0k@<2Ie;4AIjUappYDp|QCnZttY!HrJ`&oZ zR+tw%S;0V5gVC1%fw{n3Ywki#;Fx*Y^6#T|HuEEQ2a1~2QRB3>*w>4M&Sb1P9X0SG z)RrzsP4F;=;%STTn2*eVP&<_Uv1V;W%--UedZBV`wJFd#k`E~xko|`Gd*=X&WnnR zqXwvK)t<3hFVOg=)9P+=|-4Z&B?tQ1`}3#d`kllTd@FRw2tXcLKT0 zNYsksQ61Doby(lxW~laU&2Fgr15pckA2pz3@fTQtcn|vjz&cApZ@Ax39R)pi&#E9Q zPC|8*VsTqkhuu&e`A`%4$jX&+dg`ui=uimHF_Is2~xo>)QV7w+Se2bEs}HE@EN zjGAaG48x(Qn{pcJ=39np_XBExlNMjF_!esC?wQYDu>Z=){FiGGYDM9wjw+x!s)2d1 z9%^gbSo{{M{RmY3_ffCbS(d-h@>inn{*4wN#WKV{`$!Zek>hW-gEFX!NvMw7p(fDP z?2X!yffkQ7C!r=V!{WuL_A4xZy}85OZ~A^Bp{=}(TGmA_*7 z)iIK|f#r8c_170Q(Q#M>XQL)~05#4jdnGJxYXjWQT_ag8t|Cq|7zaHLVEsRT1M2r?hpM^sPE}SGudorc0hf(^g^xh z9n?g|n;)9L9qAJo9vyk7r!`OyDASjUmjJGwe*fHzSUN0<{$2Xz#mSiBWAz_%73 zME%G-gX-s^l|M0GqIN7xCa-@7^JU^+aO+DVh71kZ6jh-;s)Ih}`>2&IK|TMUVl6y? zYL`8;JCQI{yC}1=<)@+QwL$$v>uPb&%s#i_V5>M1Bd9RW;>D;HufVRj9kXC$7IzB^ zqb8hy8X(#7o1-Su&GO$vEno_2#~jOFvH_n5F2}_{br=o7sp{U=43s5_80QHnz!E%_D{~R=j@jaDE=*$k77cf8ZA7((1 zmuH>38tY>Z)Df&h?aUU`guX%bbHcoC`OhrQp4+{*B2o1#qyP7ReG=-ZrTK>04|Vn< z(0?=G0OG0mJ-$Gl?cQLo|Ig`*sEPiCNmwtByCY+;3GsSthtIJ(whZC<*8np@+?mhA ze8it)2<|eEq9%9+wZcCv_U3gbm>YF8VW_Q+M{RXIEQGyL_rPS-Q?(Jbpu>6X`9DpD zI=*NH_i-fg3)BRMhPpTDd#HxfQAf1E;{T!QZAVRH4{D1~nb%P}_1Mgt&%KGu`$+h= z-h9LChg!)9i|3#QTxRiB)I`3uIKw<|-b8&#Ju?4AO)zJe*OQDPsP~Mo4G9$tFo&Ub zVl--iS>_@uUuEU%EPpp@0S7Gq6iy|+g;jBIe%JM=de>1q^AH)QnkQ4Z*Z(ych8nmO zcEMLsJMb}TBHK_6_gQ@0=~5%or<`2;cUE;BiFyD_THF@ZLpO`xL)D*x>Tw}z#FeOe zTk%yqiqRMt;r3hFOhoNnJ=B&>j^Osy*U3yW)S+V)ms*9j<|cDH_M_e&)PQ9Qx)Z36 z>Zcv5pN^;n^~A zI`|B=^_x-kzC*3(2x{f0P`{PVq3Yd5P2?r&w^(S5mme?grw|PeXLA2XChsg~P_2ZJ z<5tv=qC8wd&X!u$y;+CCPZ>D}OLs1sTCU0SZCgcq9UFSn8ZM3r1ZkBO8Grc9rq&f_{U-cn9j-DUv` z$@|FZT{pGix0IZu)}Q`5%mcU5vaM>6e*iBN7r~{(Td+FUA*&xk-WskU&hxs}19(k% zzIKw5J9tYw^O9pCULih6X(Q}Ti*fYS7h{}^gnb$u*f;Gl}&X;k-luXMyaWY^+q zO;!i2Pt||%g>$N2jCZv2xL&WaU(v8OdHRylr!`ka>J6mL8I0r_M!CKdt~gWbM|yvC zR@aYhw2oL0*ka17P_FNy|30@!R3xtwuEKU)JO=(BysDFrHg)O$F8=Fr@}|@*(w$;` zyqG}IZ(RR<637d82Bm}-??Wo`|CA(>ew|K!pz1iQ{<^avCDQx8^J7Yk_o{Q7^a&>* zHC{i$JpHKu0Zwpgq{ewGI6YF!*VswBFX=-cy%6*%Po274`fIENj^g4sljnxj^_at?nJ^6loAygfBt==W8s0~*vqzjYYg}0sMjbelDkczd^0Oxq4*y779>QBi)X0Zy7BR^$4*|8UKODCdn ztx|)?sloLzIsbhclDN**gn3+}tUE1wIO7^e23#h$jkpMAv5xe7WN zjpM@jOUM7FR7qcY(I>;n(Ihh9XHvmVsU`_IdQs;nt+zTon#AY0Oa6YYv(CaMiD6YK z+sXAImp+Fv-MQW*&U?%WY8qbSh@0)XL;G4>iz#2tID2pecI>PmmYXapvxu!Wk zrp0>aId{{-gI*(FpIGuEoseds-eOMiW^n<(koVMS(JZ#q6lx5j^iO*2U_I+wsw8=b zF*^=NkF&H{Y>lSWo=na_ROQWF?TB}A=~IpT9bD(R%5c5P)saiT58rfNHVY5pr<11? zwG;7xQ>1yMcehi6f1Y<5>UXYv&g_l4P^`rr1O3wK`MH7~vY?d6sKAIdIzy#N3J delta 17240 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 j#v5`ucE#IA@`PLEON)12T{vZ\n" "Language-Team: JumpServer team\n" @@ -428,7 +428,7 @@ msgstr "激活" #: assets/models/asset.py:196 assets/models/cluster.py:19 #: assets/models/user.py:66 templates/_nav.html:44 -#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:138 +#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:137 msgid "Admin user" msgstr "管理用户" @@ -693,7 +693,7 @@ msgstr "ssh私钥" #: 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:139 +#: xpack/plugins/cloud/models.py:89 xpack/plugins/cloud/serializers.py:138 msgid "Node" msgstr "节点" @@ -1140,7 +1140,7 @@ msgstr "启用" msgid "-" msgstr "" -#: audits/models.py:96 xpack/plugins/cloud/const.py:24 +#: audits/models.py:96 xpack/plugins/cloud/const.py:23 msgid "Failed" msgstr "失败" @@ -1379,15 +1379,19 @@ msgstr "等待登录复核处理" msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/errors.py:217 +#: authentication/errors.py:206 +msgid "IP is not allowed" +msgstr "来源 IP 不被允许登录" + +#: authentication/errors.py:239 msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:222 authentication/views/login.py:232 +#: authentication/errors.py:244 authentication/views/login.py:232 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:231 authentication/views/login.py:247 +#: authentication/errors.py:253 authentication/views/login.py:247 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" @@ -2249,7 +2253,7 @@ msgstr "全局启用 MFA 认证" #: settings/serializers/settings.py:133 msgid "All user enable MFA" -msgstr "强制每个启用多因子认证" +msgstr "强制所有用户启用多因子认证" #: settings/serializers/settings.py:136 msgid "Batch command execution" @@ -3784,7 +3788,7 @@ msgstr "安全令牌验证" #: users/templates/users/_base_otp.html:14 users/templates/users/_user.html:13 #: users/templates/users/user_profile_update.html:55 -#: xpack/plugins/cloud/models.py:78 xpack/plugins/cloud/serializers.py:137 +#: xpack/plugins/cloud/models.py:78 xpack/plugins/cloud/serializers.py:136 msgid "Account" msgstr "账户" @@ -4774,46 +4778,42 @@ msgid "Azure (China)" msgstr "Azure (中国)" #: xpack/plugins/cloud/const.py:12 -msgid "Azure (International)" -msgstr "Azure (国际)" - -#: xpack/plugins/cloud/const.py:13 msgid "Huawei Cloud" msgstr "华为云" -#: xpack/plugins/cloud/const.py:14 +#: xpack/plugins/cloud/const.py:13 msgid "Tencent Cloud" msgstr "腾讯云" -#: xpack/plugins/cloud/const.py:15 +#: xpack/plugins/cloud/const.py:14 msgid "VMware" msgstr "" -#: xpack/plugins/cloud/const.py:19 +#: xpack/plugins/cloud/const.py:18 msgid "Instance name" msgstr "实例名称" -#: xpack/plugins/cloud/const.py:20 +#: xpack/plugins/cloud/const.py:19 msgid "Instance name and Partial IP" msgstr "实例名称和部分IP" -#: xpack/plugins/cloud/const.py:25 +#: xpack/plugins/cloud/const.py:24 msgid "Succeed" msgstr "成功" -#: xpack/plugins/cloud/const.py:29 +#: xpack/plugins/cloud/const.py:28 msgid "Unsync" msgstr "未同步" -#: xpack/plugins/cloud/const.py:30 +#: xpack/plugins/cloud/const.py:29 msgid "New Sync" msgstr "新同步" -#: xpack/plugins/cloud/const.py:31 +#: xpack/plugins/cloud/const.py:30 msgid "Synced" msgstr "已同步" -#: xpack/plugins/cloud/const.py:32 +#: xpack/plugins/cloud/const.py:31 msgid "Released" msgstr "已释放" @@ -4829,7 +4829,7 @@ msgstr "云服务商" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:118 +#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:117 msgid "Regions" msgstr "地域" @@ -4837,7 +4837,7 @@ msgstr "地域" msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:141 +#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:140 msgid "Always update" msgstr "总是更新" @@ -5029,15 +5029,15 @@ msgstr "" msgid "Subscription ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:116 +#: xpack/plugins/cloud/serializers.py:115 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers.py:117 +#: xpack/plugins/cloud/serializers.py:116 msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:140 +#: xpack/plugins/cloud/serializers.py:139 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -5130,6 +5130,9 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" +#~ msgid "Azure (International)" +#~ msgstr "Azure (国际)" + #~ msgid "Root organization only allow view and delete" #~ msgstr "全局组织仅支持 查看和删除" From 2ff01a4bb3441ab3726209407b653e2cd5bbc3e7 Mon Sep 17 00:00:00 2001 From: Bai Date: Thu, 1 Apr 2021 10:36:18 +0800 Subject: [PATCH 10/35] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNodeChildrenAddA?= =?UTF-8?q?PI=E4=B8=8D=E6=94=AF=E6=8C=81patch=E6=96=B9=E6=B3=95=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/api/node.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/assets/api/node.py b/apps/assets/api/node.py index 09508e6e3..e832b83ba 100644 --- a/apps/assets/api/node.py +++ b/apps/assets/api/node.py @@ -221,7 +221,8 @@ class NodeAddChildrenApi(generics.UpdateAPIView): serializer_class = serializers.NodeAddChildrenSerializer instance = None - def put(self, request, *args, **kwargs): + def update(self, request, *args, **kwargs): + """ 同时支持 put 和 patch 方法""" instance = self.get_object() node_ids = request.data.get("nodes") children = Node.objects.filter(id__in=node_ids) From cd9587f68ef41c77c11e449bcd9b71f9dff41e14 Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 6 Apr 2021 17:28:15 +0800 Subject: [PATCH 11/35] =?UTF-8?q?fix:=20=E7=AE=A1=E7=90=86=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=BE=93=E5=85=A5=E5=B8=A6=E5=AF=86=E7=A0=81=E7=9A=84?= =?UTF-8?q?=E7=A7=98=E9=92=A5=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/models/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/assets/models/base.py b/apps/assets/models/base.py index 404c7a991..5ed16741f 100644 --- a/apps/assets/models/base.py +++ b/apps/assets/models/base.py @@ -113,7 +113,7 @@ class AuthMixin: if self.public_key: public_key = self.public_key elif self.private_key: - public_key = ssh_pubkey_gen(self.private_key, self.password) + public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password) else: return '' From f361621ab5cc34c56c2ec13d03d49d54e15d0beb Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 6 Apr 2021 19:43:37 +0800 Subject: [PATCH 12/35] =?UTF-8?q?feat:=20=E8=B5=84=E4=BA=A7=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E6=94=AF=E6=8C=81=E6=8C=89=E5=90=8D=E7=A7=B0=E6=A8=A1?= =?UTF-8?q?=E7=B3=8A=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/perms/api/asset/asset_permission.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/perms/api/asset/asset_permission.py b/apps/perms/api/asset/asset_permission.py index 65fe316e6..ff4de7d9f 100644 --- a/apps/perms/api/asset/asset_permission.py +++ b/apps/perms/api/asset/asset_permission.py @@ -20,3 +20,4 @@ class AssetPermissionViewSet(OrgBulkModelViewSet): model = AssetPermission serializer_class = serializers.AssetPermissionSerializer filterset_class = AssetPermissionFilter + search_fields = ('name',) From 3ec78ff9be9a3dbaa8ace4e3945fe3fa55e9353b Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 6 Apr 2021 19:37:45 +0800 Subject: [PATCH 13/35] =?UTF-8?q?fix:=20=E5=88=9B=E5=BB=BA=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=9A=84=E6=97=B6=E5=80=99=E5=8A=A0=E9=94=81=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E5=B9=B6=E5=8F=91=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/api/node.py | 20 +++++++++++--------- apps/assets/locks.py | 9 +++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/assets/api/node.py b/apps/assets/api/node.py index e832b83ba..c756e9efd 100644 --- a/apps/assets/api/node.py +++ b/apps/assets/api/node.py @@ -28,6 +28,7 @@ from ..tasks import ( ) from .. import serializers from .mixin import SerializeToTreeNodeMixin +from assets.locks import NodeAddChildrenLock logger = get_logger(__file__) @@ -114,15 +115,16 @@ class NodeChildrenApi(generics.ListCreateAPIView): return super().initial(request, *args, **kwargs) def perform_create(self, serializer): - data = serializer.validated_data - _id = data.get("id") - value = data.get("value") - if not value: - value = self.instance.get_next_child_preset_name() - node = self.instance.create_child(value=value, _id=_id) - # 避免查询 full value - node._full_value = node.value - serializer.instance = node + with NodeAddChildrenLock(self.instance): + data = serializer.validated_data + _id = data.get("id") + value = data.get("value") + if not value: + value = self.instance.get_next_child_preset_name() + node = self.instance.create_child(value=value, _id=_id) + # 避免查询 full value + node._full_value = node.value + serializer.instance = node def get_object(self): pk = self.kwargs.get('pk') or self.request.query_params.get('id') diff --git a/apps/assets/locks.py b/apps/assets/locks.py index bdab57080..ad36cd2e4 100644 --- a/apps/assets/locks.py +++ b/apps/assets/locks.py @@ -1,5 +1,6 @@ from orgs.utils import current_org from common.utils.lock import DistributedLock +from assets.models import Node class NodeTreeUpdateLock(DistributedLock): @@ -18,3 +19,11 @@ class NodeTreeUpdateLock(DistributedLock): def __init__(self): name = self.get_name() super().__init__(name=name, release_on_transaction_commit=True, reentrant=True) + + +class NodeAddChildrenLock(DistributedLock): + name_template = 'assets.node.add_children.' + + def __init__(self, node: Node): + name = self.name_template.format(org_id=node.org_id) + super().__init__(name=name, release_on_transaction_commit=True) From f34fb5d9d59a44484dc00713264465b80b57aa5c Mon Sep 17 00:00:00 2001 From: xinwen Date: Wed, 7 Apr 2021 10:29:41 +0800 Subject: [PATCH 14/35] =?UTF-8?q?fix:=20=E8=AE=BF=E9=97=AE=20tokens=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=9B=B4=E6=96=B0=E7=94=A8=E6=88=B7=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E7=99=BB=E5=BD=95=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/serializers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 528cc7cf9..c86b48039 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # +from django.utils import timezone from rest_framework import serializers from common.utils import get_object_or_none @@ -44,6 +45,10 @@ class BearerTokenSerializer(serializers.Serializer): def get_keyword(obj): return 'Bearer' + def update_last_login(self, user): + user.last_login = timezone.now() + user.save(update_fields=['last_login']) + def create(self, validated_data): request = self.context.get('request') if request.user and not request.user.is_anonymous: @@ -56,6 +61,8 @@ class BearerTokenSerializer(serializers.Serializer): "user id {} not exist".format(user_id) ) token, date_expired = user.create_bearer_token(request) + self.update_last_login(user) + instance = { "token": token, "date_expired": date_expired, From 67d3b63c6d24406d80485df2bf22303c314ead5e Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 6 Apr 2021 09:53:27 +0800 Subject: [PATCH 15/35] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20key=20?= =?UTF-8?q?=E4=B8=BA=200=20=E7=9A=84=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0069_change_node_key0_to_key1.py | 59 +++++++++++++++++++ ...329_1711.py => 0034_auto_20210406_1434.py} | 11 ++-- 2 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 apps/assets/migrations/0069_change_node_key0_to_key1.py rename apps/terminal/migrations/{0033_auto_20210329_1711.py => 0034_auto_20210406_1434.py} (82%) diff --git a/apps/assets/migrations/0069_change_node_key0_to_key1.py b/apps/assets/migrations/0069_change_node_key0_to_key1.py new file mode 100644 index 000000000..4024386dc --- /dev/null +++ b/apps/assets/migrations/0069_change_node_key0_to_key1.py @@ -0,0 +1,59 @@ +from django.db import migrations +from django.db.transaction import atomic + +default_id = '00000000-0000-0000-0000-000000000002' + + +def change_key0_to_key1(apps, schema_editor): + from orgs.utils import set_current_org + + # https://stackoverflow.com/questions/28777338/django-migrations-runpython-not-able-to-call-model-methods + Organization = apps.get_model('orgs', 'Organization') + Node = apps.get_model('assets', 'Node') + + print() + org = Organization.objects.get(id=default_id) + set_current_org(org) + + exists_0 = Node.objects.filter(key__startswith='0').exists() + if not exists_0: + print(f'--> Not exist key=0 nodes, do nothing.') + return + + key_1_count = Node.objects.filter(key__startswith='1').count() + if key_1_count > 1: + print(f'--> Node key=1 have children, can`t just delete it. Please contact JumpServer team') + return + + root_node = Node.objects.filter(key='1').first() + if root_node and root_node.assets.exists(): + print(f'--> Node key=1 has assets, do nothing.') + return + + with atomic(): + if root_node: + print(f'--> Delete node key=1') + root_node.delete() + + nodes_0 = Node.objects.filter(key__startswith='0') + + for n in nodes_0: + old_key = n.key + key_list = n.key.split(':') + key_list[0] = '1' + new_key = ':'.join(key_list) + n.key = new_key + n.save() + print('--> Modify key ( {} > {} )'.format(old_key, new_key)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0010_auto_20210219_1241'), + ('assets', '0068_auto_20210312_1455'), + ] + + operations = [ + migrations.RunPython(change_key0_to_key1) + ] diff --git a/apps/terminal/migrations/0033_auto_20210329_1711.py b/apps/terminal/migrations/0034_auto_20210406_1434.py similarity index 82% rename from apps/terminal/migrations/0033_auto_20210329_1711.py rename to apps/terminal/migrations/0034_auto_20210406_1434.py index bbc45a8c7..59cc89b9b 100644 --- a/apps/terminal/migrations/0033_auto_20210329_1711.py +++ b/apps/terminal/migrations/0034_auto_20210406_1434.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1 on 2021-03-29 09:11 +# Generated by Django 3.1 on 2021-04-06 06:34 from django.db import migrations, models @@ -6,16 +6,15 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('terminal', '0032_auto_20210302_1853'), + ('terminal', '0033_auto_20210324_1008'), ] operations = [ - migrations.RenameField( + migrations.RemoveField( model_name='status', - old_name='cpu_used', - new_name='cpu_load', + name='cpu_used', ), - migrations.AlterField( + migrations.AddField( model_name='status', name='cpu_load', field=models.FloatField(default=0, verbose_name='CPU Load'), From 31982c6547c955faab2864a8f67b8207bb99c24a Mon Sep 17 00:00:00 2001 From: xinwen Date: Wed, 7 Apr 2021 18:34:01 +0800 Subject: [PATCH 16/35] =?UTF-8?q?fix:=20=E6=94=B9=E5=AF=86=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E5=85=B3=E6=8E=89=E5=91=A8=E6=9C=9F=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=86=8D=E6=89=93=E5=BC=80=EF=BC=8C=E4=BB=BB=E5=8A=A1=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ops/celery/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ops/celery/utils.py b/apps/ops/celery/utils.py index ed51dc964..959e4c907 100644 --- a/apps/ops/celery/utils.py +++ b/apps/ops/celery/utils.py @@ -72,6 +72,7 @@ def create_or_update_celery_periodic_tasks(tasks): crontab=crontab, name=name, task=detail['task'], + enabled=detail.get('enabled', True), args=json.dumps(detail.get('args', [])), kwargs=json.dumps(detail.get('kwargs', {})), description=detail.get('description') or '' From b4ef7bef5566f820dff41842d0bc891cedf4d87c Mon Sep 17 00:00:00 2001 From: xinwen Date: Wed, 7 Apr 2021 18:20:54 +0800 Subject: [PATCH 17/35] =?UTF-8?q?fix:=20=E6=8E=88=E6=9D=83=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=88=97=E8=A1=A8=20platform=20=E5=BA=94=E8=AF=A5?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/urls/api_urls.py | 1 - apps/perms/serializers/asset/user_permission.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 40eb2912e..d9b302800 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -23,4 +23,3 @@ urlpatterns = [ ] urlpatterns += router.urls - diff --git a/apps/perms/serializers/asset/user_permission.py b/apps/perms/serializers/asset/user_permission.py index be33d679b..f844b7d4d 100644 --- a/apps/perms/serializers/asset/user_permission.py +++ b/apps/perms/serializers/asset/user_permission.py @@ -4,7 +4,7 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ -from assets.models import Node, SystemUser, Asset +from assets.models import Node, SystemUser, Asset, Platform from assets.serializers import ProtocolsField from perms.serializers.asset.permission import ActionsField @@ -39,7 +39,9 @@ class AssetGrantedSerializer(serializers.ModelSerializer): 被授权资产的数据结构 """ protocols = ProtocolsField(label=_('Protocols'), required=False, read_only=True) - platform = serializers.ReadOnlyField(source='platform_base') + platform = serializers.SlugRelatedField( + slug_field='name', queryset=Platform.objects.all(), label=_("Platform") + ) class Meta: model = Asset From 32fe8f674ce6afc23790963275f8835e33185c6c Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Thu, 8 Apr 2021 10:11:46 +0800 Subject: [PATCH 18/35] perf: csv upload (#5894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit perf: 修改翻译 Co-authored-by: ibuler --- apps/common/drf/parsers/base.py | 2 +- apps/common/mixins/api.py | 4 + apps/locale/zh/LC_MESSAGES/django.po | 171 +++++++++++++++------------ 3 files changed, 103 insertions(+), 74 deletions(-) diff --git a/apps/common/drf/parsers/base.py b/apps/common/drf/parsers/base.py index 9b9379b3e..f228960f0 100644 --- a/apps/common/drf/parsers/base.py +++ b/apps/common/drf/parsers/base.py @@ -143,5 +143,5 @@ class BaseFileParser(BaseParser): return data except Exception as e: logger.error(e, exc_info=True) - raise ParseError('Parse error! ({})'.format(self.media_type)) + raise ParseError(_('Parse file error: {}').format(e)) diff --git a/apps/common/mixins/api.py b/apps/common/mixins/api.py index 7078b70b7..ea629bb84 100644 --- a/apps/common/mixins/api.py +++ b/apps/common/mixins/api.py @@ -9,6 +9,7 @@ from itertools import chain from django.db.models.signals import m2m_changed from django.core.cache import cache from django.http import JsonResponse +from django.utils.translation import ugettext as _ from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.decorators import action @@ -47,6 +48,9 @@ class RenderToJsonMixin: column_title_field_pairs = jms_context.get('column_title_field_pairs', ()) data['title'] = column_title_field_pairs + if isinstance(request.data, (list, tuple)) and not any(request.data): + error = _("Request file format may be wrong") + return Response(data={"error": error}, status=400) return Response(data=data) diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 08e9f5697..152475b14 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-22 11:29+0800\n" +"POT-Creation-Date: 2021-04-07 18:15+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -36,7 +36,7 @@ msgstr "" #: 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 #: terminal/models/storage.py:23 terminal/models/storage.py:81 -#: terminal/models/task.py:16 terminal/models/terminal.py:139 +#: terminal/models/task.py:16 terminal/models/terminal.py:99 #: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:530 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 @@ -83,7 +83,7 @@ msgstr "激活中" #: 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 #: terminal/models/storage.py:29 terminal/models/storage.py:87 -#: terminal/models/terminal.py:153 tickets/models/ticket.py:73 +#: terminal/models/terminal.py:113 tickets/models/ticket.py:73 #: users/models/group.py:16 users/models/user.py:563 #: users/templates/users/user_detail.html:115 #: users/templates/users/user_granted_database_app.html:38 @@ -211,7 +211,7 @@ msgstr "用户 `{}` 不在当前组织: `{}`" #: users/templates/users/user_profile.html:47 #: xpack/plugins/change_auth_plan/models.py:47 #: xpack/plugins/change_auth_plan/models.py:278 -#: xpack/plugins/cloud/serializers.py:44 +#: xpack/plugins/cloud/serializers.py:51 msgid "Username" msgstr "用户名" @@ -303,7 +303,7 @@ msgstr "集群" #: applications/serializers/attrs/application_category/db.py:11 #: ops/models/adhoc.py:146 #: users/templates/users/user_granted_database_app.html:36 -#: xpack/plugins/cloud/serializers.py:42 +#: xpack/plugins/cloud/serializers.py:49 msgid "Host" msgstr "主机" @@ -313,7 +313,7 @@ msgstr "主机" #: applications/serializers/attrs/application_type/oracle.py:11 #: applications/serializers/attrs/application_type/pgsql.py:11 #: assets/models/asset.py:188 assets/models/domain.py:53 -#: xpack/plugins/cloud/serializers.py:43 +#: xpack/plugins/cloud/serializers.py:50 msgid "Port" msgstr "端口" @@ -346,7 +346,7 @@ msgstr "目标URL" #: 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:46 +#: xpack/plugins/cloud/serializers.py:53 msgid "Password" msgstr "密码" @@ -366,15 +366,15 @@ msgstr "删除失败,存在关联资产" msgid "Number required" msgstr "需要为数字" -#: assets/api/node.py:64 +#: assets/api/node.py:65 msgid "You can't update the root node name" msgstr "不能修改根节点名称" -#: assets/api/node.py:71 +#: assets/api/node.py:72 msgid "You can't delete the root node ({})" msgstr "不能删除根节点 ({})" -#: assets/api/node.py:74 +#: assets/api/node.py:75 msgid "Deletion failed and the node contains children or assets" msgstr "删除失败,节点包含子节点或资产" @@ -428,7 +428,7 @@ msgstr "激活" #: assets/models/asset.py:196 assets/models/cluster.py:19 #: assets/models/user.py:66 templates/_nav.html:44 -#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:137 +#: xpack/plugins/cloud/models.py:92 xpack/plugins/cloud/serializers.py:146 msgid "Admin user" msgstr "管理用户" @@ -588,6 +588,7 @@ msgid "Operator" msgstr "运营商" #: assets/models/cluster.py:36 assets/models/group.py:34 +#: xpack/plugins/cloud/providers/nutanix.py:30 msgid "Default" msgstr "默认" @@ -693,7 +694,7 @@ msgstr "ssh私钥" #: 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:138 +#: xpack/plugins/cloud/models.py:89 xpack/plugins/cloud/serializers.py:147 msgid "Node" msgstr "节点" @@ -1140,7 +1141,7 @@ msgstr "启用" msgid "-" msgstr "" -#: audits/models.py:96 xpack/plugins/cloud/const.py:23 +#: audits/models.py:96 xpack/plugins/cloud/const.py:25 msgid "Failed" msgstr "失败" @@ -1387,11 +1388,11 @@ msgstr "来源 IP 不被允许登录" msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:244 authentication/views/login.py:232 +#: authentication/errors.py:244 authentication/views/login.py:235 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:253 authentication/views/login.py:247 +#: authentication/errors.py:253 authentication/views/login.py:250 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" @@ -1565,7 +1566,7 @@ msgstr "复制成功" msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:178 +#: authentication/views/login.py:181 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1573,19 +1574,19 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:183 +#: authentication/views/login.py:186 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:215 +#: authentication/views/login.py:218 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:216 +#: authentication/views/login.py:219 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" -#: authentication/views/login.py:231 authentication/views/login.py:246 +#: authentication/views/login.py:234 authentication/views/login.py:249 msgid "Please change your password" msgstr "请修改密码" @@ -1609,7 +1610,11 @@ msgstr "对象" #: common/drf/parsers/base.py:17 msgid "The file content overflowed (The maximum length `{}` bytes)" -msgstr "文件内容益处 (最大长度 `{}` 字节)" +msgstr "文件内容太大 (最大长度 `{}` 字节)" + +#: common/drf/parsers/base.py:146 +msgid "Parse file error: {}" +msgstr "解析文件错误: {}" #: common/exceptions.py:15 #, python-format @@ -1660,6 +1665,10 @@ msgstr "" msgid "Encrypt field using Secret Key" msgstr "" +#: common/mixins/api.py:52 +msgid "Request file format may be wrong" +msgstr "上传的文件格式错误 或 其它类型资源的文件" + #: common/mixins/models.py:33 msgid "is discard" msgstr "" @@ -2991,6 +3000,10 @@ msgstr "较高" msgid "Normal" msgstr "正常" +#: terminal/const.py:34 +msgid "Offline" +msgstr "" + #: terminal/exceptions.py:8 msgid "Bulk create not support" msgstr "不支持批量创建" @@ -3011,27 +3024,31 @@ msgstr "回放" msgid "Date end" msgstr "结束日期" -#: terminal/models/status.py:13 +#: terminal/models/status.py:18 msgid "Session Online" msgstr "在线会话" -#: terminal/models/status.py:14 -msgid "CPU Usage" -msgstr "CPU使用" +#: terminal/models/status.py:19 +msgid "CPU Load" +msgstr "CPU负载" -#: terminal/models/status.py:15 +#: terminal/models/status.py:20 msgid "Memory Used" msgstr "内存使用" -#: terminal/models/status.py:16 +#: terminal/models/status.py:21 +msgid "Disk Used" +msgstr "磁盘使用" + +#: terminal/models/status.py:22 msgid "Connections" msgstr "连接数" -#: terminal/models/status.py:17 +#: terminal/models/status.py:23 msgid "Threads" msgstr "线程数" -#: terminal/models/status.py:18 +#: terminal/models/status.py:24 msgid "Boot Time" msgstr "运行时间" @@ -3039,46 +3056,30 @@ msgstr "运行时间" msgid "Args" msgstr "参数" -#: terminal/models/terminal.py:142 +#: terminal/models/terminal.py:102 msgid "type" msgstr "类型" -#: terminal/models/terminal.py:144 +#: terminal/models/terminal.py:104 msgid "Remote Address" msgstr "远端地址" -#: terminal/models/terminal.py:145 +#: terminal/models/terminal.py:105 msgid "SSH Port" msgstr "SSH端口" -#: terminal/models/terminal.py:146 +#: terminal/models/terminal.py:106 msgid "HTTP Port" msgstr "HTTP端口" -#: terminal/models/terminal.py:147 +#: terminal/models/terminal.py:107 msgid "Command storage" msgstr "命令存储" -#: terminal/models/terminal.py:148 +#: terminal/models/terminal.py:108 msgid "Replay storage" msgstr "录像存储" -#: terminal/serializers/components.py:9 -msgid "System cpu load (1 minutes)" -msgstr "系统CPU负载 (1分钟)" - -#: terminal/serializers/components.py:12 -msgid "System memory used percent" -msgstr "系统内存使用百分比" - -#: terminal/serializers/components.py:15 -msgid "System disk used percent" -msgstr "系统磁盘使用百分比" - -#: terminal/serializers/components.py:19 -msgid "Session active count" -msgstr "活跃会话数量" - #: terminal/serializers/session.py:30 msgid "User ID" msgstr "用户 ID" @@ -3164,18 +3165,22 @@ msgstr "索引" msgid "Doc type" msgstr "文档类型" -#: terminal/serializers/terminal.py:47 terminal/serializers/terminal.py:55 +#: terminal/serializers/storage.py:185 +msgid "Ignore Certificate Verification" +msgstr "" + +#: terminal/serializers/terminal.py:66 terminal/serializers/terminal.py:74 msgid "Not found" msgstr "没有发现" -#: terminal/utils.py:79 +#: terminal/utils.py:78 #, python-format msgid "" "Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $" "%(command)s" msgstr "危险命令告警: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s" -#: terminal/utils.py:87 +#: terminal/utils.py:86 #, python-format msgid "" "\n" @@ -3204,12 +3209,12 @@ msgstr "" "
\n" " " -#: terminal/utils.py:114 +#: terminal/utils.py:113 #, python-format msgid "Insecure Web Command Execution Alert: [%(name)s]" msgstr "Web页面-> 命令执行 告警: [%(name)s]" -#: terminal/utils.py:122 +#: terminal/utils.py:121 #, python-format msgid "" "\n" @@ -3788,7 +3793,7 @@ msgstr "安全令牌验证" #: users/templates/users/_base_otp.html:14 users/templates/users/_user.html:13 #: users/templates/users/user_profile_update.html:55 -#: xpack/plugins/cloud/models.py:78 xpack/plugins/cloud/serializers.py:136 +#: xpack/plugins/cloud/models.py:78 xpack/plugins/cloud/serializers.py:145 msgid "Account" msgstr "账户" @@ -4778,42 +4783,50 @@ msgid "Azure (China)" msgstr "Azure (中国)" #: xpack/plugins/cloud/const.py:12 +msgid "Azure (International)" +msgstr "Azure (国际)" + +#: xpack/plugins/cloud/const.py:13 msgid "Huawei Cloud" msgstr "华为云" -#: xpack/plugins/cloud/const.py:13 +#: xpack/plugins/cloud/const.py:14 msgid "Tencent Cloud" msgstr "腾讯云" -#: xpack/plugins/cloud/const.py:14 +#: xpack/plugins/cloud/const.py:15 msgid "VMware" msgstr "" -#: xpack/plugins/cloud/const.py:18 +#: xpack/plugins/cloud/const.py:16 xpack/plugins/cloud/providers/nutanix.py:13 +msgid "Nutanix" +msgstr "" + +#: xpack/plugins/cloud/const.py:20 msgid "Instance name" msgstr "实例名称" -#: xpack/plugins/cloud/const.py:19 +#: xpack/plugins/cloud/const.py:21 msgid "Instance name and Partial IP" msgstr "实例名称和部分IP" -#: xpack/plugins/cloud/const.py:24 +#: xpack/plugins/cloud/const.py:26 msgid "Succeed" msgstr "成功" -#: xpack/plugins/cloud/const.py:28 +#: xpack/plugins/cloud/const.py:30 msgid "Unsync" msgstr "未同步" -#: xpack/plugins/cloud/const.py:29 +#: xpack/plugins/cloud/const.py:31 msgid "New Sync" msgstr "新同步" -#: xpack/plugins/cloud/const.py:30 +#: xpack/plugins/cloud/const.py:32 msgid "Synced" msgstr "已同步" -#: xpack/plugins/cloud/const.py:31 +#: xpack/plugins/cloud/const.py:33 msgid "Released" msgstr "已释放" @@ -4829,7 +4842,7 @@ msgstr "云服务商" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:117 +#: xpack/plugins/cloud/models.py:81 xpack/plugins/cloud/serializers.py:126 msgid "Regions" msgstr "地域" @@ -4837,7 +4850,7 @@ msgstr "地域" msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:140 +#: xpack/plugins/cloud/models.py:95 xpack/plugins/cloud/serializers.py:149 msgid "Always update" msgstr "总是更新" @@ -5029,15 +5042,15 @@ msgstr "" msgid "Subscription ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:115 +#: xpack/plugins/cloud/serializers.py:124 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers.py:116 +#: xpack/plugins/cloud/serializers.py:125 msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:139 +#: xpack/plugins/cloud/serializers.py:148 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -5130,8 +5143,20 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" -#~ msgid "Azure (International)" -#~ msgstr "Azure (国际)" +#~ msgid "CPU Usage" +#~ msgstr "CPU使用" + +#~ msgid "System cpu load (1 minutes)" +#~ msgstr "系统CPU负载 (1分钟)" + +#~ msgid "System memory used percent" +#~ msgstr "系统内存使用百分比" + +#~ msgid "System disk used percent" +#~ msgstr "系统磁盘使用百分比" + +#~ msgid "Session active count" +#~ msgstr "活跃会话数量" #~ msgid "Root organization only allow view and delete" #~ msgstr "全局组织仅支持 查看和删除" From 8b1e202e68758efb736db911a2fa773e22204419 Mon Sep 17 00:00:00 2001 From: liuboF2c <30886198+liuboF2c@users.noreply.github.com> Date: Thu, 8 Apr 2021 13:55:58 +0800 Subject: [PATCH 19/35] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=85=A8=E5=B1=80=E7=BB=84=E7=BB=87=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E7=A7=B0=20(#5919)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: liubo --- apps/jumpserver/conf.py | 1 + apps/jumpserver/settings/custom.py | 3 +++ apps/locale/zh/LC_MESSAGES/django.mo | Bin 73503 -> 73666 bytes apps/locale/zh/LC_MESSAGES/django.po | 8 ++++++++ apps/orgs/models.py | 4 ++-- apps/settings/serializers/settings.py | 4 ++++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index f4a1c6641..d09a81bd2 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -143,6 +143,7 @@ class Config(dict): 'REDIS_DB_SESSION': 5, 'REDIS_DB_WS': 6, + 'GLOBAL_ORG_DISPLAY_NAME': '', 'SITE_URL': 'http://localhost:8080', 'CAPTCHA_TEST_MODE': None, 'TOKEN_EXPIRATION': 3600 * 24, diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 394c8707a..89b8d6d53 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -120,3 +120,6 @@ CONNECTION_TOKEN_ENABLED = CONFIG.CONNECTION_TOKEN_ENABLED DISK_CHECK_ENABLED = CONFIG.DISK_CHECK_ENABLED FORGOT_PASSWORD_URL = CONFIG.FORGOT_PASSWORD_URL + +# 自定义默认组织名 +GLOBAL_ORG_DISPLAY_NAME = CONFIG.GLOBAL_ORG_DISPLAY_NAME diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index b8d77bd5756a5c2893b9c951e17a430682c60d5e..2864b472f044cd880033eefde5ae81c4dbfab2be 100644 GIT binary patch delta 22151 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 delta 22028 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 Nz24F|iC_1S{|Bw Date: Thu, 8 Apr 2021 14:18:53 +0800 Subject: [PATCH 20/35] =?UTF-8?q?style:=20=E4=BC=98=E5=8C=96=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E7=BB=84=E7=BB=87=E8=AE=BE=E7=BD=AE=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=20(#5921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat:支持配置全局组织的显示名称 * style: 优化全局组织设置相关代码 Co-authored-by: liubo --- apps/orgs/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/orgs/models.py b/apps/orgs/models.py index 310bc709a..72f47e1e3 100644 --- a/apps/orgs/models.py +++ b/apps/orgs/models.py @@ -193,7 +193,8 @@ class Organization(models.Model): @classmethod def root(cls): - return cls(id=cls.ROOT_ID, name=settings.GLOBAL_ORG_DISPLAY_NAME if settings.GLOBAL_ORG_DISPLAY_NAME else cls.ROOT_NAME) + name = settings.GLOBAL_ORG_DISPLAY_NAME or cls.ROOT_NAME + return cls(id=cls.ROOT_ID, name=name) def is_root(self): return self.id == self.ROOT_ID From 607b7fd29f4f3f161d8938aac96be357412ede79 Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 8 Apr 2021 12:47:49 +0800 Subject: [PATCH 21/35] =?UTF-8?q?feat:=20MFA=20=E7=99=BB=E5=BD=95=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/mfa.py | 2 +- apps/authentication/errors.py | 47 ++++++++++---- apps/authentication/mixins.py | 35 ++++++++--- apps/authentication/views/mfa.py | 4 +- apps/users/api/user.py | 10 +-- apps/users/models/user.py | 11 ++-- apps/users/utils.py | 104 ++++++++++++++++++++----------- 7 files changed, 143 insertions(+), 70 deletions(-) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index f95593bbe..ac0ba66f4 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -29,7 +29,7 @@ class MFAChallengeApi(AuthMixin, CreateAPIView): if not valid: self.request.session['auth_mfa'] = '' raise errors.MFAFailedError( - username=user.username, request=self.request + username=user.username, request=self.request, ip=self.get_request_ip() ) else: self.request.session['auth_mfa'] = '1' diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 576a4e4b1..06631742a 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -6,9 +6,7 @@ from django.conf import settings from common.exceptions import JMSException from .signals import post_auth_failed -from users.utils import ( - increase_login_failed_count, get_login_failed_count -) +from users.utils import LoginBlockUtil, MFABlockUtils reason_password_failed = 'password_failed' reason_password_decrypt_failed = 'password_decrypt_failed' @@ -52,7 +50,15 @@ block_login_msg = _( "The account has been locked " "(please contact admin to unlock it or try again after {} minutes)" ) -mfa_failed_msg = _("MFA code invalid, or ntp sync server time") +block_mfa_msg = _( + "The account has been locked " + "(please contact admin to unlock it or try again after {} minutes)" +) +mfa_failed_msg = _( + "MFA code invalid, or ntp sync server time, " + "You can also try {times_try} times " + "(The account will be temporarily locked for {block_time} minutes)" +) mfa_required_msg = _("MFA required") mfa_unset_msg = _("MFA not set, please set it first") @@ -80,7 +86,7 @@ class AuthFailedNeedBlockMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - increase_login_failed_count(self.username, self.ip) + LoginBlockUtil(self.username, self.ip).incr_failed_count() class AuthFailedError(Exception): @@ -107,13 +113,12 @@ class AuthFailedError(Exception): class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFailedError): def __init__(self, error, username, ip, request): super().__init__(error=error, username=username, ip=ip, request=request) - times_up = settings.SECURITY_LOGIN_LIMIT_COUNT - times_failed = get_login_failed_count(username, ip) - times_try = int(times_up) - int(times_failed) + util = LoginBlockUtil(username, ip) + times_remainder = util.get_remainder_times() block_time = settings.SECURITY_LOGIN_LIMIT_TIME default_msg = invalid_login_msg.format( - times_try=times_try, block_time=block_time + times_try=times_remainder, block_time=block_time ) if error == reason_password_failed: self.msg = default_msg @@ -123,12 +128,32 @@ class CredentialError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFail class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): error = reason_mfa_failed - msg = mfa_failed_msg + msg: str - def __init__(self, username, request): + def __init__(self, username, request, ip): + util = MFABlockUtils(username, ip) + util.incr_failed_count() + + times_remainder = util.get_remainder_times() + block_time = settings.SECURITY_LOGIN_LIMIT_TIME + + if times_remainder: + self.msg = mfa_failed_msg.format( + times_try=times_remainder, block_time=block_time + ) + else: + self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) super().__init__(username=username, request=request) +class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError): + error = 'block_mfa' + + def __init__(self, username, request, ip): + self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) + super().__init__(username=username, request=request, ip=ip) + + class MFAUnsetError(AuthFailedNeedLogMixin, AuthFailedError): error = reason_mfa_unset msg = mfa_unset_msg diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 747127ca9..e13a88c87 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -15,9 +15,7 @@ from django.shortcuts import reverse from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get from users.models import User -from users.utils import ( - is_block_login, clean_failed_count -) +from users.utils import LoginBlockUtil, MFABlockUtils from . import errors from .utils import rsa_decrypt from .signals import post_auth_success, post_auth_failed @@ -117,7 +115,7 @@ class AuthMixin: else: username = self.request.POST.get("username") ip = self.get_request_ip() - if is_block_login(username, ip): + if LoginBlockUtil(username, ip).is_block(): logger.warn('Ip was blocked' + ': ' + username + ':' + ip) exception = errors.BlockLoginError(username=username, ip=ip) if raise_exception: @@ -197,7 +195,7 @@ class AuthMixin: self._check_password_require_reset_or_not(user) self._check_passwd_is_too_simple(user, password) - clean_failed_count(username, ip) + LoginBlockUtil(username, ip).clean_failed_count() request.session['auth_password'] = 1 request.session['user_id'] = str(user.id) request.session['auto_login'] = auto_login @@ -253,15 +251,34 @@ class AuthMixin: raise errors.MFAUnsetError(user, self.request, url) raise errors.MFARequiredError() + def mark_mfa_ok(self): + self.request.session['auth_mfa'] = 1 + self.request.session['auth_mfa_time'] = time.time() + self.request.session['auth_mfa_type'] = 'otp' + + def check_mfa_is_block(self, username, ip, raise_exception=True): + if MFABlockUtils(username, ip).is_block(): + logger.warn('Ip was blocked' + ': ' + username + ':' + ip) + exception = errors.BlockMFAError(username=username, request=self.request, ip=ip) + if raise_exception: + raise exception + else: + return exception + def check_user_mfa(self, code): user = self.get_user_from_session() + ip = self.get_request_ip() + self.check_mfa_is_block(user.username, ip) ok = user.check_mfa(code) if ok: - self.request.session['auth_mfa'] = 1 - self.request.session['auth_mfa_time'] = time.time() - self.request.session['auth_mfa_type'] = 'otp' + self.mark_mfa_ok() return - raise errors.MFAFailedError(username=user.username, request=self.request) + + raise errors.MFAFailedError( + username=user.username, + request=self.request, + ip=ip + ) def get_ticket(self): from tickets.models import Ticket diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py index bedbf9bcf..f3c2602cb 100644 --- a/apps/authentication/views/mfa.py +++ b/apps/authentication/views/mfa.py @@ -22,10 +22,12 @@ class UserLoginOtpView(mixins.AuthMixin, FormView): try: self.check_user_mfa(otp_code) return redirect_to_guard_view() - except errors.MFAFailedError as e: + except (errors.MFAFailedError, errors.BlockMFAError) as e: form.add_error('otp_code', e.msg) return super().form_invalid(form) except Exception as e: logger.error(e) + import traceback + traceback.print_exception() return redirect_to_guard_view() diff --git a/apps/users/api/user.py b/apps/users/api/user.py index 5973be849..6f39f40e5 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -16,7 +16,7 @@ from common.mixins import CommonApiMixin from common.utils import get_logger from orgs.utils import current_org from orgs.models import ROLE as ORG_ROLE, OrganizationMember -from users.utils import send_reset_mfa_mail +from users.utils import send_reset_mfa_mail, LoginBlockUtil, MFABlockUtils from .. import serializers from ..serializers import UserSerializer, UserRetrieveSerializer, MiniUserSerializer, InviteSerializer from .mixins import UserQuerysetMixin @@ -190,16 +190,12 @@ class UserChangePasswordApi(UserQuerysetMixin, generics.RetrieveUpdateAPIView): class UserUnblockPKApi(UserQuerysetMixin, generics.UpdateAPIView): permission_classes = (IsOrgAdmin,) serializer_class = serializers.UserSerializer - key_prefix_limit = "_LOGIN_LIMIT_{}_{}" - key_prefix_block = "_LOGIN_BLOCK_{}" def perform_update(self, serializer): user = self.get_object() username = user.username if user else '' - key_limit = self.key_prefix_limit.format(username, '*') - key_block = self.key_prefix_block.format(username) - cache.delete_pattern(key_limit) - cache.delete(key_block) + LoginBlockUtil.unblock_user(username) + MFABlockUtils.unblock_user(username) class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView): diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 096ac260d..52dccbeaa 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -669,10 +669,13 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): @property def login_blocked(self): - key_prefix_block = "_LOGIN_BLOCK_{}" - key_block = key_prefix_block.format(self.username) - blocked = bool(cache.get(key_block)) - return blocked + from users.utils import LoginBlockUtil, MFABlockUtils + if LoginBlockUtil.is_user_block(self.username): + return True + if MFABlockUtils.is_user_block(self.username): + return True + + return False def delete(self, using=None, keep_parents=False): if self.pk == 1 or self.username == 'admin': diff --git a/apps/users/utils.py b/apps/users/utils.py index 960848f08..374ead56f 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -322,50 +322,80 @@ def check_password_rules(password): return bool(match_obj) -key_prefix_limit = "_LOGIN_LIMIT_{}_{}" -key_prefix_block = "_LOGIN_BLOCK_{}" +class BlockUtil: + BLOCK_KEY_TMPL: str + + def __init__(self, username): + self.block_key = self.BLOCK_KEY_TMPL.format(username) + self.key_ttl = int(settings.SECURITY_LOGIN_LIMIT_TIME) * 60 + + def block(self): + cache.set(self.block_key, True, self.key_ttl) + + def is_block(self): + return bool(cache.get(self.block_key)) -# def increase_login_failed_count(key_limit, key_block): -def increase_login_failed_count(username, ip): - key_limit = key_prefix_limit.format(username, ip) - count = cache.get(key_limit) - count = count + 1 if count else 1 +class BlockUtilBase: + LIMIT_KEY_TMPL: str + BLOCK_KEY_TMPL: str - limit_time = settings.SECURITY_LOGIN_LIMIT_TIME - cache.set(key_limit, count, int(limit_time)*60) + def __init__(self, username, ip): + self.username = username + self.ip = ip + self.limit_key = self.LIMIT_KEY_TMPL.format(username, ip) + self.block_key = self.BLOCK_KEY_TMPL.format(username) + self.key_ttl = int(settings.SECURITY_LOGIN_LIMIT_TIME) * 60 + + def get_remainder_times(self): + times_up = settings.SECURITY_LOGIN_LIMIT_COUNT + times_failed = self.get_failed_count() + times_remainder = int(times_up) - int(times_failed) + return times_remainder + + def incr_failed_count(self): + limit_key = self.limit_key + count = cache.get(limit_key, 0) + count += 1 + cache.set(limit_key, count, self.key_ttl) + + limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT + if count >= limit_count: + cache.set(self.block_key, True, self.key_ttl) + + def get_failed_count(self): + count = cache.get(self.limit_key, 0) + return count + + def clean_failed_count(self): + cache.delete(self.limit_key) + cache.delete(self.block_key) + + @classmethod + def unblock_user(cls, username): + key_limit = cls.LIMIT_KEY_TMPL.format(username, '*') + key_block = cls.BLOCK_KEY_TMPL.format(username) + # Redis 尽量不要用通配 + cache.delete_pattern(key_limit) + cache.delete(key_block) + + @classmethod + def is_user_block(cls, username): + block_key = cls.BLOCK_KEY_TMPL.format(username) + return bool(cache.get(block_key)) + + def is_block(self): + return bool(cache.get(self.block_key)) -def get_login_failed_count(username, ip): - key_limit = key_prefix_limit.format(username, ip) - count = cache.get(key_limit, 0) - return count +class LoginBlockUtil(BlockUtilBase): + LIMIT_KEY_TMPL = "_LOGIN_LIMIT_{}_{}" + BLOCK_KEY_TMPL = "_LOGIN_BLOCK_{}" -def clean_failed_count(username, ip): - key_limit = key_prefix_limit.format(username, ip) - key_block = key_prefix_block.format(username) - cache.delete(key_limit) - cache.delete(key_block) - - -def is_block_login(username, ip): - count = get_login_failed_count(username, ip) - key_block = key_prefix_block.format(username) - - limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT - limit_time = settings.SECURITY_LOGIN_LIMIT_TIME - - if count >= limit_count: - cache.set(key_block, 1, int(limit_time)*60) - if count and count >= limit_count: - return True - - -def is_need_unblock(key_block): - if not cache.get(key_block): - return False - return True +class MFABlockUtils(BlockUtilBase): + LIMIT_KEY_TMPL = "_MFA_LIMIT_{}_{}" + BLOCK_KEY_TMPL = "_MFA_BLOCK_{}" def construct_user_email(username, email): From 7e4f20f443ec201a90e5118defd328b325314f57 Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 8 Apr 2021 14:59:14 +0800 Subject: [PATCH 22/35] =?UTF-8?q?refactor:=20=E7=A7=BB=E5=8A=A8=20Permissi?= =?UTF-8?q?onsMixin=20=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/common/mixins/views.py | 18 +++++++++++++++++- apps/common/permissions.py | 15 --------------- apps/jumpserver/views/index.py | 3 ++- apps/ops/views.py | 4 ++-- apps/users/views/profile/password.py | 3 ++- apps/users/views/profile/pubkey.py | 3 ++- apps/users/views/profile/reset.py | 3 ++- 7 files changed, 27 insertions(+), 22 deletions(-) diff --git a/apps/common/mixins/views.py b/apps/common/mixins/views.py index b6685def5..13f365662 100644 --- a/apps/common/mixins/views.py +++ b/apps/common/mixins/views.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- # # coding: utf-8 - +from django.contrib.auth.mixins import UserPassesTestMixin from django.utils import timezone __all__ = ["DatetimeSearchMixin"] +from rest_framework import permissions + class DatetimeSearchMixin: date_format = '%Y-%m-%d' @@ -36,3 +38,17 @@ class DatetimeSearchMixin: def get(self, request, *args, **kwargs): self.get_date_range() return super().get(request, *args, **kwargs) + + +class PermissionsMixin(UserPassesTestMixin): + permission_classes = [permissions.IsAuthenticated] + + def get_permissions(self): + return self.permission_classes + + def test_func(self): + permission_classes = self.get_permissions() + for permission_class in permission_classes: + if not permission_class().has_permission(self.request, self): + return False + return True \ No newline at end of file diff --git a/apps/common/permissions.py b/apps/common/permissions.py index 65a57827d..7df83046d 100644 --- a/apps/common/permissions.py +++ b/apps/common/permissions.py @@ -2,7 +2,6 @@ # import time from rest_framework import permissions -from django.contrib.auth.mixins import UserPassesTestMixin from django.conf import settings from orgs.utils import current_org @@ -95,20 +94,6 @@ class WithBootstrapToken(permissions.BasePermission): return settings.BOOTSTRAP_TOKEN == request_bootstrap_token -class PermissionsMixin(UserPassesTestMixin): - permission_classes = [permissions.IsAuthenticated] - - def get_permissions(self): - return self.permission_classes - - def test_func(self): - permission_classes = self.get_permissions() - for permission_class in permission_classes: - if not permission_class().has_permission(self.request, self): - return False - return True - - class UserCanAnyPermCurrentOrg(permissions.BasePermission): def has_permission(self, request, view): return current_org.can_any_by(request.user) diff --git a/apps/jumpserver/views/index.py b/apps/jumpserver/views/index.py index 5050d72c0..8f974a483 100644 --- a/apps/jumpserver/views/index.py +++ b/apps/jumpserver/views/index.py @@ -1,6 +1,7 @@ from django.views.generic import TemplateView from django.shortcuts import redirect -from common.permissions import PermissionsMixin, IsValidUser +from common.permissions import IsValidUser +from common.mixins.views import PermissionsMixin __all__ = ['IndexView'] diff --git a/apps/ops/views.py b/apps/ops/views.py index 9ae2d9755..7b18b0f46 100644 --- a/apps/ops/views.py +++ b/apps/ops/views.py @@ -3,8 +3,8 @@ from django.views.generic import TemplateView from django.conf import settings -from common.permissions import PermissionsMixin, IsOrgAdmin, IsOrgAuditor - +from common.permissions import IsOrgAdmin, IsOrgAuditor +from common.mixins.views import PermissionsMixin __all__ = ['CeleryTaskLogView'] diff --git a/apps/users/views/profile/password.py b/apps/users/views/profile/password.py index 1fbbd64a7..e2cd8f8e2 100644 --- a/apps/users/views/profile/password.py +++ b/apps/users/views/profile/password.py @@ -11,9 +11,10 @@ from django.contrib.auth import logout as auth_logout from common.utils import get_logger from common.permissions import ( - PermissionsMixin, IsValidUser, + IsValidUser, UserCanUpdatePassword ) +from common.mixins.views import PermissionsMixin from ... import forms from ...models import User from ...utils import ( diff --git a/apps/users/views/profile/pubkey.py b/apps/users/views/profile/pubkey.py index 4010fd996..e2125f0cc 100644 --- a/apps/users/views/profile/pubkey.py +++ b/apps/users/views/profile/pubkey.py @@ -8,9 +8,10 @@ from django.views.generic.edit import UpdateView from common.utils import get_logger, ssh_key_gen from common.permissions import ( - PermissionsMixin, IsValidUser, + IsValidUser, UserCanUpdateSSHKey, ) +from common.mixins.views import PermissionsMixin from ... import forms from ...models import User diff --git a/apps/users/views/profile/reset.py b/apps/users/views/profile/reset.py index 8c676c756..73c34396b 100644 --- a/apps/users/views/profile/reset.py +++ b/apps/users/views/profile/reset.py @@ -13,7 +13,8 @@ from formtools.wizard.views import SessionWizardView from django.views.generic import FormView from common.utils import get_object_or_none -from common.permissions import PermissionsMixin, IsValidUser +from common.permissions import IsValidUser +from common.mixins.views import PermissionsMixin from ...models import User from ...utils import ( send_reset_password_mail, get_password_check_rules, check_password_rules, From 03ad7777d0e0aa05c8045614c56a2a4d610d7338 Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 8 Apr 2021 16:51:44 +0800 Subject: [PATCH 23/35] =?UTF-8?q?fix:=20=E7=99=BB=E5=BD=95=E6=97=A5?= =?UTF-8?q?=E5=BF=97=20user=5Fagent=20=E8=BF=87=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/audits/signals_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index c7cf24337..80ff68d1d 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -153,7 +153,7 @@ def generate_data(username, request): 'username': username, 'ip': login_ip, 'type': login_type, - 'user_agent': user_agent, + 'user_agent': user_agent[0:254], 'datetime': timezone.now(), 'backend': get_login_backend(request) } From 749f9d3f8108d607ce6048b68f4418cc30eb2c8a Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 8 Apr 2021 17:36:24 +0800 Subject: [PATCH 24/35] =?UTF-8?q?fix(terminal):=20=E4=BF=AE=E5=A4=8Dsessio?= =?UTF-8?q?n=20id=20=E9=95=BF=E5=BA=A6=E8=AF=AF=E5=86=99=E4=B8=BA=2035=20?= =?UTF-8?q?=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/serializers/terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/terminal/serializers/terminal.py b/apps/terminal/serializers/terminal.py index 765634393..aee43e833 100644 --- a/apps/terminal/serializers/terminal.py +++ b/apps/terminal/serializers/terminal.py @@ -13,7 +13,7 @@ from ..models import ( class StatusSerializer(serializers.ModelSerializer): sessions = serializers.ListSerializer( - child=serializers.CharField(max_length=35), write_only=True + child=serializers.CharField(max_length=36), write_only=True ) class Meta: From f0bad5f1074df47edb9b0cac0a9294afb0be0fdd Mon Sep 17 00:00:00 2001 From: xinwen Date: Fri, 9 Apr 2021 14:42:51 +0800 Subject: [PATCH 25/35] =?UTF-8?q?fix:=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=B5=8B=E8=AF=95=20cookie=20=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/views/login.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 9f628e6e8..e1a3ab4b6 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -69,6 +69,7 @@ class UserLoginView(mixins.AuthMixin, FormView): new_form = form_cls(data=form.data) new_form._errors = form.errors context = self.get_context_data(form=new_form) + self.request.session.set_test_cookie() return self.render_to_response(context) except (errors.PasswdTooSimple, errors.PasswordRequireResetError) as e: return redirect(e.url) From f11d3c1cf2470bf25ceeb8313dfe4616a594e919 Mon Sep 17 00:00:00 2001 From: xinwen Date: Fri, 9 Apr 2021 15:01:22 +0800 Subject: [PATCH 26/35] =?UTF-8?q?fix:=20=E8=BF=87=E6=9C=9F=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=99=BB=E5=BD=95=E6=8F=90=E7=A4=BA=E6=97=A0=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/errors.py | 2 ++ apps/authentication/mixins.py | 2 +- apps/users/models/user.py | 5 +++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 06631742a..8f9ba9307 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -16,6 +16,7 @@ reason_user_not_exist = 'user_not_exist' reason_password_expired = 'password_expired' reason_user_invalid = 'user_invalid' reason_user_inactive = 'user_inactive' +reason_user_expired = 'user_expired' reason_backend_not_match = 'backend_not_match' reason_acl_not_allow = 'acl_not_allow' @@ -28,6 +29,7 @@ reason_choices = { reason_password_expired: _("Password expired"), reason_user_invalid: _('Disabled or expired'), reason_user_inactive: _("This account is inactive."), + reason_user_expired: _("This account is expired"), reason_backend_not_match: _("Auth backend not match"), reason_acl_not_allow: _("ACL is not allowed"), } diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index e13a88c87..89d7b85fd 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -171,7 +171,7 @@ class AuthMixin: if not user: self.raise_credential_error(errors.reason_password_failed) elif user.is_expired: - self.raise_credential_error(errors.reason_user_inactive) + self.raise_credential_error(errors.reason_user_expired) elif not user.is_active: self.raise_credential_error(errors.reason_user_inactive) return user diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 52dccbeaa..fab14e252 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -667,6 +667,11 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): else: return user_default + def unblock_login(self): + from users.utils import LoginBlockUtil, MFABlockUtils + LoginBlockUtil.unblock_user(self.username) + MFABlockUtils.unblock_user(self.username) + @property def login_blocked(self): from users.utils import LoginBlockUtil, MFABlockUtils From a2e3979916dcda05ea3f8b915dff60ba098be29f Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 12 Apr 2021 17:16:30 +0800 Subject: [PATCH 27/35] =?UTF-8?q?fix:=20org=5Fmapping=20=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=BA=BF=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/orgs/signals_handler/common.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/orgs/signals_handler/common.py b/apps/orgs/signals_handler/common.py index 59dc2806f..f59c4cb47 100644 --- a/apps/orgs/signals_handler/common.py +++ b/apps/orgs/signals_handler/common.py @@ -46,12 +46,19 @@ def subscribe_orgs_mapping_expire(sender, **kwargs): logger.debug("Start subscribe for expire orgs mapping from memory") def keep_subscribe(): - subscribe = orgs_mapping_for_memory_pub_sub.subscribe() - for message in subscribe.listen(): - if message['type'] != 'message': - continue - Organization.expire_orgs_mapping() - logger.debug('Expire orgs mapping') + while True: + try: + subscribe = orgs_mapping_for_memory_pub_sub.subscribe() + for message in subscribe.listen(): + if message['type'] != 'message': + continue + if message['data'] == b'error': + raise ValueError + Organization.expire_orgs_mapping() + logger.debug('Expire orgs mapping') + except Exception as e: + logger.exception(f'subscribe_orgs_mapping_expire: {e}') + Organization.expire_orgs_mapping() t = threading.Thread(target=keep_subscribe) t.daemon = True From 682f6b2fb9a29bdeb51a421d494c8ad641a92729 Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 12 Apr 2021 16:35:03 +0800 Subject: [PATCH 28/35] =?UTF-8?q?fix:=20=E8=B5=84=E4=BA=A7=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=85=B3=E7=B3=BB=E5=8F=98=E5=8C=96=E6=97=B6=E4=B9=9F?= =?UTF-8?q?=E8=A6=81=E6=B8=85=E7=A9=BA=20root=20=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E7=9A=84=20node=5Fassets=5Fmapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/models/node.py | 9 +++++ .../signals_handler/node_assets_mapping.py | 34 +++++++++++++------ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/apps/assets/models/node.py b/apps/assets/models/node.py index ad17a8be9..973df4b4a 100644 --- a/apps/assets/models/node.py +++ b/apps/assets/models/node.py @@ -307,6 +307,15 @@ class NodeAllAssetsMappingMixin: org_id = str(org_id) cls.orgid_nodekey_assetsid_mapping.pop(org_id, None) + @classmethod + def expire_all_orgs_node_all_asset_ids_mapping_from_memory(cls): + orgs = Organization.objects.all() + org_ids = [str(org.id) for org in orgs] + org_ids.append(Organization.ROOT_ID) + + for id in org_ids: + cls.expire_node_all_asset_ids_mapping_from_memory(id) + # get order: from memory -> (from cache -> to generate) @classmethod def get_node_all_asset_ids_mapping_from_cache_or_generate_to_cache(cls, org_id): diff --git a/apps/assets/signals_handler/node_assets_mapping.py b/apps/assets/signals_handler/node_assets_mapping.py index 4e2b0d07b..efa3cb29f 100644 --- a/apps/assets/signals_handler/node_assets_mapping.py +++ b/apps/assets/signals_handler/node_assets_mapping.py @@ -13,6 +13,7 @@ from common.signals import django_ready from common.utils.connection import RedisPubSub from common.utils import get_logger from assets.models import Asset, Node +from orgs.models import Organization logger = get_logger(__file__) @@ -36,13 +37,18 @@ node_assets_mapping_for_memory_pub_sub = NodeAssetsMappingForMemoryPubSub() def expire_node_assets_mapping_for_memory(org_id): # 所有进程清除(自己的 memory 数据) org_id = str(org_id) - node_assets_mapping_for_memory_pub_sub.publish(org_id) + root_org_id = Organization.ROOT_ID + # 当前进程清除(cache 数据) logger.debug( "Expire node assets id mapping from cache of org={}, pid={}" "".format(org_id, os.getpid()) ) Node.expire_node_all_asset_ids_mapping_from_cache(org_id) + Node.expire_node_all_asset_ids_mapping_from_cache(root_org_id) + + node_assets_mapping_for_memory_pub_sub.publish(org_id) + node_assets_mapping_for_memory_pub_sub.publish(root_org_id) @receiver(post_save, sender=Node) @@ -73,16 +79,22 @@ def subscribe_node_assets_mapping_expire(sender, **kwargs): logger.debug("Start subscribe for expire node assets id mapping from memory") def keep_subscribe(): - subscribe = node_assets_mapping_for_memory_pub_sub.subscribe() - for message in subscribe.listen(): - if message["type"] != "message": - continue - org_id = message['data'].decode() - Node.expire_node_all_asset_ids_mapping_from_memory(org_id) - logger.debug( - "Expire node assets id mapping from memory of org={}, pid={}" - "".format(str(org_id), os.getpid()) - ) + while True: + try: + subscribe = node_assets_mapping_for_memory_pub_sub.subscribe() + for message in subscribe.listen(): + if message["type"] != "message": + continue + org_id = message['data'].decode() + Node.expire_node_all_asset_ids_mapping_from_memory(org_id) + logger.debug( + "Expire node assets id mapping from memory of org={}, pid={}" + "".format(str(org_id), os.getpid()) + ) + except Exception as e: + logger.exception(f'subscribe_node_assets_mapping_expire: {e}') + Node.expire_all_orgs_node_all_asset_ids_mapping_from_memory() + t = threading.Thread(target=keep_subscribe) t.daemon = True t.start() From 6616374c30332fed96991663232ddbf4f01cbe61 Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 12 Apr 2021 11:30:09 +0800 Subject: [PATCH 29/35] fix: subscribe_settings_change --- apps/settings/signals_handler.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/settings/signals_handler.py b/apps/settings/signals_handler.py index 9625df3f6..ef649f732 100644 --- a/apps/settings/signals_handler.py +++ b/apps/settings/signals_handler.py @@ -71,13 +71,19 @@ def subscribe_settings_change(sender, **kwargs): logger.debug("Start subscribe setting change") def keep_subscribe(): - sub = setting_pub_sub.subscribe() - for msg in sub.listen(): - if msg["type"] != "message": - continue - item = msg['data'].decode() - logger.debug("Found setting change: {}".format(str(item))) - Setting.refresh_item(item) + while True: + try: + sub = setting_pub_sub.subscribe() + for msg in sub.listen(): + if msg["type"] != "message": + continue + item = msg['data'].decode() + logger.debug("Found setting change: {}".format(str(item))) + Setting.refresh_item(item) + except Exception as e: + logger.exception(f'subscribe_settings_change: {e}') + Setting.refresh_all_settings() + t = threading.Thread(target=keep_subscribe) t.daemon = True t.start() From 68b6236de23a4bca8a348aca936b025e2a19e909 Mon Sep 17 00:00:00 2001 From: xinwen Date: Mon, 12 Apr 2021 12:32:15 +0800 Subject: [PATCH 30/35] =?UTF-8?q?fix:=20SSO=20=E7=99=BB=E5=BD=95=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/audits/signals_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/audits/signals_handler.py b/apps/audits/signals_handler.py index 80ff68d1d..c604e9b6b 100644 --- a/apps/audits/signals_handler.py +++ b/apps/audits/signals_handler.py @@ -44,6 +44,7 @@ class AuthBackendLabelMapping(LazyObject): backend_label_mapping[backend] = source.label backend_label_mapping[settings.AUTH_BACKEND_PUBKEY] = _('SSH Key') backend_label_mapping[settings.AUTH_BACKEND_MODEL] = _('Password') + backend_label_mapping[settings.AUTH_BACKEND_SSO] = _('SSO') return backend_label_mapping def _setup(self): From b97759687d39d4acb0c92432dc1c85d09c2de521 Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 13 Apr 2021 15:46:49 +0800 Subject: [PATCH 31/35] =?UTF-8?q?fix:=20=E9=82=80=E8=AF=B7=E7=94=A8?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E8=A7=A6=E5=8F=91=E4=BF=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/users/api/user.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/users/api/user.py b/apps/users/api/user.py index 6f39f40e5..ebab1ec3c 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -1,8 +1,8 @@ # ~*~ coding: utf-8 ~*~ -from django.core.cache import cache +from collections import defaultdict + from django.utils.translation import ugettext as _ from rest_framework.decorators import action -from django.conf import settings from rest_framework import generics from rest_framework.response import Response from rest_framework_bulk import BulkModelViewSet @@ -155,10 +155,17 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): serializer = serializer_cls(data=data, many=True) serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data + + users_by_role = defaultdict(list) for i in validated_data: - i['org_id'] = current_org.org_id() - relations = [OrganizationMember(**i) for i in validated_data] - OrganizationMember.objects.bulk_create(relations, ignore_conflicts=True) + users_by_role[i['role']].append(i['user']) + + OrganizationMember.objects.add_users_by_role( + current_org, + users=users_by_role[ORG_ROLE.USER], + admins=users_by_role[ORG_ROLE.ADMIN], + auditors=users_by_role[ORG_ROLE.AUDITOR] + ) return Response(serializer.data, status=201) @action(methods=['post'], detail=True, permission_classes=(IsOrgAdmin,)) From ce6b9de07c2ef5e1df5b6aac6c309bd909ee8e34 Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 13 Apr 2021 09:18:29 +0800 Subject: [PATCH 32/35] =?UTF-8?q?fix:=20ES=20=E8=87=AA=E5=8A=A8=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/api/command.py | 17 ++++++++++++++++- apps/terminal/backends/command/es.py | 11 +++++++++++ apps/terminal/models/storage.py | 9 +++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/terminal/api/command.py b/apps/terminal/api/command.py index f969e24d7..d7868e4a0 100644 --- a/apps/terminal/api/command.py +++ b/apps/terminal/api/command.py @@ -11,6 +11,7 @@ from rest_framework.response import Response from rest_framework.decorators import action from django.template import loader +from common.http import is_true from terminal.models import CommandStorage, Command from terminal.filters import CommandFilter from orgs.utils import current_org @@ -140,7 +141,21 @@ class CommandViewSet(viewsets.ModelViewSet): if session_id and not command_storage_id: # 会话里的命令列表肯定会提供 session_id,这里防止 merge 的时候取全量的数据 return self.merge_all_storage_list(request, *args, **kwargs) - return super().list(request, *args, **kwargs) + + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + query_all = self.request.query_params.get('all', False) + if is_true(query_all): + # 适配像 ES 这种没有指定分页只返回少量数据的情况 + queryset = queryset[:] + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) def get_queryset(self): command_storage_id = self.request.query_params.get('command_storage_id') diff --git a/apps/terminal/backends/command/es.py b/apps/terminal/backends/command/es.py index fc0f247f4..d8197391d 100644 --- a/apps/terminal/backends/command/es.py +++ b/apps/terminal/backends/command/es.py @@ -10,6 +10,7 @@ import inspect from django.db.models import QuerySet as DJQuerySet from elasticsearch import Elasticsearch from elasticsearch.helpers import bulk +from elasticsearch.exceptions import RequestError from common.utils.common import lazyproperty from common.utils import get_logger @@ -31,6 +32,15 @@ class CommandStore(): kwargs['verify_certs'] = None self.es = Elasticsearch(hosts=hosts, max_retries=0, **kwargs) + def pre_use_check(self): + self._ensure_index_exists() + + def _ensure_index_exists(self): + try: + self.es.indices.create(self.index) + except RequestError: + pass + @staticmethod def make_data(command): data = dict( @@ -234,6 +244,7 @@ class QuerySet(DJQuerySet): uqs = QuerySet(self._command_store_config) uqs._method_calls = self._method_calls.copy() uqs._slice = self._slice + uqs.model = self.model return uqs def count(self, limit_to_max_result_window=True): diff --git a/apps/terminal/models/storage.py b/apps/terminal/models/storage.py index 4826e2eef..883e5f67a 100644 --- a/apps/terminal/models/storage.py +++ b/apps/terminal/models/storage.py @@ -76,6 +76,15 @@ class CommandStorage(CommonModelMixin): qs.model = Command return qs + def save(self, force_insert=False, force_update=False, using=None, + update_fields=None): + super().save() + + if self.type in TYPE_ENGINE_MAPPING: + engine_mod = import_module(TYPE_ENGINE_MAPPING[self.type]) + backend = engine_mod.CommandStore(self.config) + backend.pre_use_check() + class ReplayStorage(CommonModelMixin): name = models.CharField(max_length=128, verbose_name=_("Name"), unique=True) From 9944474ba028946770953dc566254d97c17ce63a Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 13 Apr 2021 18:33:00 +0800 Subject: [PATCH 33/35] =?UTF-8?q?fix:=20settings=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E4=B8=8D=E7=A8=B3=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/settings/signals_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/settings/signals_handler.py b/apps/settings/signals_handler.py index ef649f732..6264fb9e6 100644 --- a/apps/settings/signals_handler.py +++ b/apps/settings/signals_handler.py @@ -6,6 +6,7 @@ import threading from django.dispatch import receiver from django.db.models.signals import post_save, pre_save from django.utils.functional import LazyObject +from django.db import close_old_connections from jumpserver.utils import current_request from common.decorator import on_transaction_commit @@ -75,6 +76,7 @@ def subscribe_settings_change(sender, **kwargs): try: sub = setting_pub_sub.subscribe() for msg in sub.listen(): + close_old_connections() if msg["type"] != "message": continue item = msg['data'].decode() @@ -82,6 +84,7 @@ def subscribe_settings_change(sender, **kwargs): Setting.refresh_item(item) except Exception as e: logger.exception(f'subscribe_settings_change: {e}') + close_old_connections() Setting.refresh_all_settings() t = threading.Thread(target=keep_subscribe) From e2d5b69510ce47cdf8a3ac569ecd4cea378fcf8b Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 13 Apr 2021 16:21:51 +0800 Subject: [PATCH 34/35] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E7=9B=91=E6=B5=8B=EF=BC=8C=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20health=20check=20=E7=9A=84=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/jumpserver/api.py | 67 +++++++++++++++++++++++++++++- apps/jumpserver/conf.py | 1 + apps/jumpserver/settings/custom.py | 1 + apps/jumpserver/urls.py | 3 +- apps/jumpserver/views/other.py | 9 +--- 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/apps/jumpserver/api.py b/apps/jumpserver/api.py index e31a1d843..bda2537c8 100644 --- a/apps/jumpserver/api.py +++ b/apps/jumpserver/api.py @@ -1,3 +1,5 @@ +import time + from django.core.cache import cache from django.utils import timezone from django.utils.timesince import timesince @@ -6,6 +8,8 @@ from django.http.response import JsonResponse, HttpResponse from rest_framework.views import APIView from rest_framework.permissions import AllowAny from collections import Counter +from django.conf import settings +from rest_framework.response import Response from users.models import User from assets.models import Asset @@ -307,7 +311,68 @@ class IndexApi(TotalCountMixin, DatesLoginMetricMixin, APIView): return JsonResponse(data, status=200) -class PrometheusMetricsApi(APIView): +class HealthApiMixin(APIView): + def is_token_right(self): + token = self.request.query_params.get('token') + ok_token = settings.HEALTH_CHECK_TOKEN + if ok_token and token != ok_token: + return False + return True + + def check_permissions(self, request): + if not self.is_token_right(): + msg = 'Health check token error, ' \ + 'Please set query param in url and same with setting HEALTH_CHECK_TOKEN. ' \ + 'eg: $PATH/?token=$HEALTH_CHECK_TOKEN' + self.permission_denied(request, message={'error': msg}, code=403) + + +class HealthCheckView(HealthApiMixin): + permission_classes = (AllowAny,) + + @staticmethod + def get_db_status(): + t1 = time.time() + try: + User.objects.first() + t2 = time.time() + return True, t2 - t1 + except: + t2 = time.time() + return False, t2 - t1 + + def get_redis_status(self): + key = 'HEALTH_CHECK' + + t1 = time.time() + try: + value = '1' + cache.set(key, '1', 10) + got = cache.get(key) + t2 = time.time() + if value == got: + return True, t2 -t1 + return False, t2 -t1 + except: + t2 = time.time() + return False, t2 - t1 + + def get(self, request): + redis_status, redis_time = self.get_redis_status() + db_status, db_time = self.get_db_status() + status = all([redis_status, db_status]) + data = { + 'status': status, + 'db_status': db_status, + 'db_time': db_time, + 'redis_status': redis_status, + 'redis_time': redis_time, + 'time': int(time.time()) + } + return Response(data) + + +class PrometheusMetricsApi(HealthApiMixin): permission_classes = (AllowAny,) def get(self, request, *args, **kwargs): diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index d09a81bd2..179376828 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -289,6 +289,7 @@ class Config(dict): 'SESSION_SAVE_EVERY_REQUEST': True, 'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False, 'FORGOT_PASSWORD_URL': '', + 'HEALTH_CHECK_TOKEN': '' } def compatible_auth_openid_of_key(self): diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 89b8d6d53..936b27582 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -123,3 +123,4 @@ FORGOT_PASSWORD_URL = CONFIG.FORGOT_PASSWORD_URL # 自定义默认组织名 GLOBAL_ORG_DISPLAY_NAME = CONFIG.GLOBAL_ORG_DISPLAY_NAME +HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 044d09310..759c6f271 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -48,7 +48,8 @@ urlpatterns = [ path('', views.IndexView.as_view(), name='index'), path('api/v1/', include(api_v1)), re_path('api/(?P\w+)/(?Pv\d)/.*', views.redirect_format_api), - path('api/health/', views.HealthCheckView.as_view(), name="health"), + path('api/health/', api.HealthCheckView.as_view(), name="health"), + path('api/v1/health/', api.HealthCheckView.as_view(), name="health_v1"), # External apps url path('core/auth/captcha/', include('captcha.urls')), path('core/', include(app_view_patterns)), diff --git a/apps/jumpserver/views/other.py b/apps/jumpserver/views/other.py index da8046bfc..9ab561c4c 100644 --- a/apps/jumpserver/views/other.py +++ b/apps/jumpserver/views/other.py @@ -17,7 +17,7 @@ from common.http import HttpResponseTemporaryRedirect __all__ = [ - 'LunaView', 'I18NView', 'KokoView', 'WsView', 'HealthCheckView', + 'LunaView', 'I18NView', 'KokoView', 'WsView', 'redirect_format_api', 'redirect_old_apps_view', 'UIView' ] @@ -64,13 +64,6 @@ def redirect_old_apps_view(request, *args, **kwargs): return HttpResponseTemporaryRedirect(new_path) -class HealthCheckView(APIView): - permission_classes = (AllowAny,) - - def get(self, request): - return JsonResponse({"status": 1, "time": int(time.time())}) - - class WsView(APIView): ws_port = settings.HTTP_LISTEN_PORT + 1 From fdb8416cac0a10487ba147727bf5c231f024c307 Mon Sep 17 00:00:00 2001 From: ibuler Date: Tue, 13 Apr 2021 17:37:29 +0800 Subject: [PATCH 35/35] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=9C=A8=E7=BA=BF=E4=BC=9A=E8=AF=9D=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E4=B8=8D=E5=AF=B9=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/models/terminal.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/terminal/models/terminal.py b/apps/terminal/models/terminal.py index e13902251..77c9b1ce8 100644 --- a/apps/terminal/models/terminal.py +++ b/apps/terminal/models/terminal.py @@ -7,6 +7,7 @@ from django.conf import settings from common.utils import get_logger from users.models import User +from orgs.utils import tmp_to_root_org from .status import Status from .. import const from ..const import ComponentStatusChoices as StatusChoice @@ -112,7 +113,6 @@ class Terminal(StorageMixin, TerminalStatusMixin, models.Model): date_created = models.DateTimeField(auto_now_add=True) comment = models.TextField(blank=True, verbose_name=_('Comment')) - @property def is_active(self): if self.user and self.user.is_active: @@ -126,7 +126,8 @@ class Terminal(StorageMixin, TerminalStatusMixin, models.Model): self.user.save() def get_online_sessions(self): - return Session.objects.filter(terminal=self, is_finished=False) + with tmp_to_root_org(): + return Session.objects.filter(terminal=self, is_finished=False) def get_online_session_count(self): return self.get_online_sessions().count()