Merge pull request #6634 from jumpserver/dev

v2.13.0 rc1
pull/6697/head
Jiangjie.Bai 2021-08-12 19:51:48 +08:00 committed by GitHub
commit 96206384c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
149 changed files with 3884 additions and 1726 deletions

1
.gitignore vendored
View File

@ -39,3 +39,4 @@ logs/*
.vagrant/
release/*
releashe
/apps/script.py

View File

@ -2,7 +2,7 @@
from django.db import models
from django.utils.translation import ugettext_lazy as _
from .base import BaseACL, BaseACLQuerySet
from ..utils import contains_ip
from common.utils.ip import contains_ip
class ACLManager(models.Manager):

View File

@ -3,7 +3,7 @@ from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.models import OrgModelMixin, OrgManager
from .base import BaseACL, BaseACLQuerySet
from ..utils import contains_ip
from common.utils.ip import contains_ip
class ACLManager(OrgManager):

View File

@ -3,7 +3,7 @@ from rest_framework import serializers
from common.drf.serializers import BulkModelSerializer
from orgs.utils import current_org
from ..models import LoginACL
from ..utils import is_ip_address, is_ip_network, is_ip_segment
from common.utils.ip import is_ip_address, is_ip_network, is_ip_segment
__all__ = ['LoginACLSerializer', ]

View File

@ -1,68 +0,0 @@
from ipaddress import ip_network, ip_address
def is_ip_address(address):
""" 192.168.10.1 """
try:
ip_address(address)
except ValueError:
return False
else:
return True
def is_ip_network(ip):
""" 192.168.1.0/24 """
try:
ip_network(ip)
except ValueError:
return False
else:
return True
def is_ip_segment(ip):
""" 10.1.1.1-10.1.1.20 """
if '-' not in ip:
return False
ip_address1, ip_address2 = ip.split('-')
return is_ip_address(ip_address1) and is_ip_address(ip_address2)
def in_ip_segment(ip, ip_segment):
ip1, ip2 = ip_segment.split('-')
ip1 = int(ip_address(ip1))
ip2 = int(ip_address(ip2))
ip = int(ip_address(ip))
return min(ip1, ip2) <= ip <= max(ip1, ip2)
def contains_ip(ip, ip_group):
"""
ip_group:
[192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64.]
"""
if '*' in ip_group:
return True
for _ip in ip_group:
if is_ip_address(_ip):
# 192.168.10.1
if ip == _ip:
return True
elif is_ip_network(_ip) and is_ip_address(ip):
# 192.168.1.0/24
if ip_address(ip) in ip_network(_ip):
return True
elif is_ip_segment(_ip) and is_ip_address(ip):
# 10.1.1.1-10.1.1.20
if in_ip_segment(ip, _ip):
return True
else:
# is domain name
if ip == _ip:
return True
return False

View File

@ -1,4 +1,4 @@
from .application import *
from .application_user import *
from .account import *
from .mixin import *
from .remote_app import *

View File

@ -0,0 +1,70 @@
# coding: utf-8
#
from django_filters import rest_framework as filters
from django.conf import settings
from django.db.models import F, Value, CharField
from django.db.models.functions import Concat
from django.http import Http404
from common.drf.filters import BaseFilterSet
from common.drf.api import JMSModelViewSet
from common.utils import unique
from perms.models import ApplicationPermission
from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify
from .. import serializers
class AccountFilterSet(BaseFilterSet):
username = filters.CharFilter(field_name='username')
app = filters.CharFilter(field_name='applications', lookup_expr='exact')
app_name = filters.CharFilter(field_name='app_name', lookup_expr='exact')
class Meta:
model = ApplicationPermission
fields = ['type', 'category']
class ApplicationAccountViewSet(JMSModelViewSet):
permission_classes = (IsOrgAdmin, )
search_fields = ['username', 'app_name']
filterset_class = AccountFilterSet
filterset_fields = ['username', 'app_name', 'type', 'category']
serializer_class = serializers.ApplicationAccountSerializer
http_method_names = ['get', 'put', 'patch', 'options']
def get_queryset(self):
queryset = ApplicationPermission.objects.all() \
.annotate(uid=Concat(
'applications', Value('_'), 'system_users', output_field=CharField()
)) \
.annotate(systemuser=F('system_users')) \
.annotate(systemuser_display=F('system_users__name')) \
.annotate(username=F('system_users__username')) \
.annotate(password=F('system_users__password')) \
.annotate(app=F('applications')) \
.annotate(app_name=F("applications__name")) \
.values('username', 'password', 'systemuser', 'systemuser_display',
'app', 'app_name', 'category', 'type', 'uid')
return queryset
def get_object(self):
obj = self.get_queryset().filter(
uid=self.kwargs['pk']
).first()
if not obj:
raise Http404()
return obj
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
queryset_list = unique(queryset, key=lambda x: (x['app'], x['systemuser']))
return queryset_list
class ApplicationAccountSecretViewSet(ApplicationAccountViewSet):
serializer_class = serializers.ApplicationAccountSecretSerializer
permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify]
http_method_names = ['get', 'options']

View File

@ -2,7 +2,10 @@
#
from orgs.mixins.api import OrgBulkModelViewSet
from rest_framework.decorators import action
from rest_framework.response import Response
from common.tree import TreeNodeSerializer
from ..hands import IsOrgAdminOrAppUser
from .. import serializers
from ..models import Application
@ -13,7 +16,22 @@ __all__ = ['ApplicationViewSet']
class ApplicationViewSet(OrgBulkModelViewSet):
model = Application
filterset_fields = ('name', 'type', 'category')
search_fields = filterset_fields
filterset_fields = {
'name': ['exact'],
'category': ['exact'],
'type': ['exact', 'in'],
}
search_fields = ('name', 'type', 'category')
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = serializers.ApplicationSerializer
serializer_classes = {
'default': serializers.ApplicationSerializer,
'get_tree': TreeNodeSerializer
}
@action(methods=['GET'], detail=False, url_path='tree')
def get_tree(self, request, *args, **kwargs):
show_count = request.query_params.get('show_count', '1') == '1'
queryset = self.filter_queryset(self.get_queryset())
tree_nodes = Application.create_tree_nodes(queryset, show_count=show_count)
serializer = self.get_serializer(tree_nodes, many=True)
return Response(serializer.data)

View File

@ -1,55 +0,0 @@
# coding: utf-8
#
from rest_framework import generics
from django.conf import settings
from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify
from .. import serializers
from ..models import Application, ApplicationUser
from perms.models import ApplicationPermission
class ApplicationUserListApi(generics.ListAPIView):
permission_classes = (IsOrgAdmin, )
filterset_fields = ('name', 'username')
search_fields = filterset_fields
serializer_class = serializers.ApplicationUserSerializer
_application = None
@property
def application(self):
if self._application is None:
app_id = self.request.query_params.get('application_id')
if app_id:
self._application = Application.objects.get(id=app_id)
return self._application
def get_serializer_context(self):
context = super().get_serializer_context()
context.update({
'application': self.application
})
return context
def get_queryset(self):
queryset = ApplicationUser.objects.none()
if not self.application:
return queryset
system_user_ids = ApplicationPermission.objects.filter(applications=self.application)\
.values_list('system_users', flat=True)
if not system_user_ids:
return queryset
queryset = ApplicationUser.objects.filter(id__in=system_user_ids)
return queryset
class ApplicationUserAuthInfoListApi(ApplicationUserListApi):
serializer_class = serializers.ApplicationUserWithAuthInfoSerializer
http_method_names = ['get']
permission_classes = [IsOrgAdminOrAppUser]
def get_permissions(self):
if settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify]
return super().get_permissions()

View File

@ -1,89 +1,53 @@
from orgs.models import Organization
from django.utils.translation import ugettext as _
from common.tree import TreeNode
from orgs.models import Organization
from ..models import Application
__all__ = ['SerializeApplicationToTreeNodeMixin']
class SerializeApplicationToTreeNodeMixin:
@staticmethod
def _serialize_db(db):
return {
'id': db.id,
'name': db.name,
'title': db.name,
'pId': '',
'open': False,
'iconSkin': 'database',
'meta': {'type': 'database_app'}
}
@staticmethod
def _serialize_remote_app(remote_app):
return {
'id': remote_app.id,
'name': remote_app.name,
'title': remote_app.name,
'pId': '',
'open': False,
'isParent': False,
'iconSkin': 'chrome',
'meta': {'type': 'remote_app'}
}
@staticmethod
def _serialize_cloud(cloud):
return {
'id': cloud.id,
'name': cloud.name,
'title': cloud.name,
'pId': '',
'open': False,
'isParent': False,
'iconSkin': 'k8s',
'meta': {'type': 'k8s_app'}
}
def _serialize_application(self, application):
method_name = f'_serialize_{application.category}'
data = getattr(self, method_name)(application)
data.update({
'pId': application.org.id,
'org_name': application.org_name
})
return data
def serialize_applications(self, applications):
data = [self._serialize_application(application) for application in applications]
return data
@staticmethod
def _serialize_organization(org):
return {
'id': org.id,
'name': org.name,
'title': org.name,
'pId': '',
'open': True,
'isParent': True,
'meta': {
'type': 'node'
}
}
def serialize_organizations(self, organizations):
data = [self._serialize_organization(org) for org in organizations]
return data
@staticmethod
def filter_organizations(applications):
organization_ids = set(applications.values_list('org_id', flat=True))
organizations = [Organization.get_instance(org_id) for org_id in organization_ids]
organizations.sort(key=lambda x: x.name)
return organizations
@staticmethod
def create_root_node():
name = _('My applications')
node = TreeNode(**{
'id': 'applications',
'name': name,
'title': name,
'pId': '',
'open': True,
'isParent': True,
'meta': {
'type': 'root'
}
})
return node
def serialize_applications_with_org(self, applications):
root_node = self.create_root_node()
tree_nodes = [root_node]
organizations = self.filter_organizations(applications)
data_organizations = self.serialize_organizations(organizations)
data_applications = self.serialize_applications(applications)
data = data_organizations + data_applications
return data
for i, org in enumerate(organizations):
# 组织节点
org_node = org.as_tree_node(pid=root_node.id)
tree_nodes.append(org_node)
org_applications = applications.filter(org_id=org.id)
count = org_applications.count()
org_node.name += '({})'.format(count)
# 各应用节点
apps_nodes = Application.create_tree_nodes(
queryset=org_applications, root_node=org_node,
show_empty=False
)
tree_nodes += apps_nodes
return tree_nodes

View File

@ -1,11 +1,10 @@
# coding: utf-8
#
from django.db.models import TextChoices
from django.utils.translation import ugettext_lazy as _
class ApplicationCategoryChoices(TextChoices):
class AppCategory(TextChoices):
db = 'db', _('Database')
remote_app = 'remote_app', _('Remote app')
cloud = 'cloud', 'Cloud'
@ -15,7 +14,7 @@ class ApplicationCategoryChoices(TextChoices):
return dict(cls.choices).get(category, '')
class ApplicationTypeChoices(TextChoices):
class AppType(TextChoices):
# db category
mysql = 'mysql', 'MySQL'
oracle = 'oracle', 'Oracle'
@ -31,19 +30,38 @@ class ApplicationTypeChoices(TextChoices):
# cloud category
k8s = 'k8s', 'Kubernetes'
@classmethod
def category_types_mapper(cls):
return {
AppCategory.db: [cls.mysql, cls.oracle, cls.pgsql, cls.mariadb],
AppCategory.remote_app: [cls.chrome, cls.mysql_workbench, cls.vmware_client, cls.custom],
AppCategory.cloud: [cls.k8s]
}
@classmethod
def type_category_mapper(cls):
mapper = {}
for category, tps in cls.category_types_mapper().items():
for tp in tps:
mapper[tp] = category
return mapper
@classmethod
def get_label(cls, tp):
return dict(cls.choices).get(tp, '')
@classmethod
def db_types(cls):
return [cls.mysql.value, cls.oracle.value, cls.pgsql.value, cls.mariadb.value]
return [tp.value for tp in cls.category_types_mapper()[AppCategory.db]]
@classmethod
def remote_app_types(cls):
return [cls.chrome.value, cls.mysql_workbench.value, cls.vmware_client.value, cls.custom.value]
return [tp.value for tp in cls.category_types_mapper()[AppCategory.remote_app]]
@classmethod
def cloud_types(cls):
return [cls.k8s.value]
return [tp.value for tp in cls.category_types_mapper()[AppCategory.cloud]]

View File

View File

@ -1,19 +1,174 @@
from collections import defaultdict
from django.db import models
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.models import OrgModelMixin
from common.mixins import CommonModelMixin
from common.tree import TreeNode
from assets.models import Asset, SystemUser
from .. import const
class Application(CommonModelMixin, OrgModelMixin):
class ApplicationTreeNodeMixin:
id: str
name: str
type: str
category: str
@classmethod
def create_choice_node(cls, c, id_, pid, tp, opened=False, counts=None,
show_empty=True, show_count=True):
count = counts.get(c.value, 0)
if count == 0 and not show_empty:
return None
label = c.label
if count is not None and show_count:
label = '{} ({})'.format(label, count)
data = {
'id': id_,
'name': label,
'title': label,
'pId': pid,
'isParent': bool(count),
'open': opened,
'iconSkin': '',
'meta': {
'type': tp,
'data': {
'name': c.name,
'value': c.value
}
}
}
return TreeNode(**data)
@classmethod
def create_root_tree_node(cls, queryset, show_count=True):
count = queryset.count() if show_count else None
root_id = 'applications'
root_name = _('Applications')
if count is not None and show_count:
root_name = '{} ({})'.format(root_name, count)
node = TreeNode(**{
'id': root_id,
'name': root_name,
'title': root_name,
'pId': '',
'isParent': True,
'open': True,
'iconSkin': '',
'meta': {
'type': 'applications_root',
}
})
return node
@classmethod
def create_category_tree_nodes(cls, root_node, counts=None, show_empty=True, show_count=True):
nodes = []
categories = const.AppType.category_types_mapper().keys()
for category in categories:
i = root_node.id + '_' + category.value
node = cls.create_choice_node(
category, i, pid=root_node.id, tp='category',
counts=counts, opened=False, show_empty=show_empty,
show_count=show_count
)
if not node:
continue
nodes.append(node)
return nodes
@classmethod
def create_types_tree_nodes(cls, root_node, counts, show_empty=True, show_count=True):
nodes = []
type_category_mapper = const.AppType.type_category_mapper()
for tp in const.AppType.type_category_mapper().keys():
category = type_category_mapper.get(tp)
pid = root_node.id + '_' + category.value
i = root_node.id + '_' + tp.value
node = cls.create_choice_node(
tp, i, pid, tp='type', counts=counts, opened=False,
show_empty=show_empty, show_count=show_count
)
if not node:
continue
nodes.append(node)
return nodes
@staticmethod
def get_tree_node_counts(queryset):
counts = defaultdict(int)
values = queryset.values_list('type', 'category')
for i in values:
tp = i[0]
category = i[1]
counts[tp] += 1
counts[category] += 1
return counts
@classmethod
def create_tree_nodes(cls, queryset, root_node=None, show_empty=True, show_count=True):
counts = cls.get_tree_node_counts(queryset)
tree_nodes = []
# 根节点有可能是组织名称
if root_node is None:
root_node = cls.create_root_tree_node(queryset, show_count=show_count)
tree_nodes.append(root_node)
# 类别的节点
tree_nodes += cls.create_category_tree_nodes(
root_node, counts, show_empty=show_empty,
show_count=show_count
)
# 类型的节点
tree_nodes += cls.create_types_tree_nodes(
root_node, counts, show_empty=show_empty,
show_count=show_count
)
# 应用的节点
for app in queryset:
pid = root_node.id + '_' + app.type
tree_nodes.append(app.as_tree_node(pid))
return tree_nodes
def as_tree_node(self, pid):
icon_skin_category_mapper = {
'remote_app': 'chrome',
'db': 'database',
'cloud': 'cloud'
}
icon_skin = icon_skin_category_mapper.get(self.category, 'file')
node = TreeNode(**{
'id': str(self.id),
'name': self.name,
'title': self.name,
'pId': pid,
'isParent': False,
'open': False,
'iconSkin': icon_skin,
'meta': {
'type': 'application',
'data': {
'category': self.category,
'type': self.type,
}
}
})
return node
class Application(CommonModelMixin, OrgModelMixin, ApplicationTreeNodeMixin):
name = models.CharField(max_length=128, verbose_name=_('Name'))
category = models.CharField(
max_length=16, choices=const.ApplicationCategoryChoices.choices, verbose_name=_('Category')
max_length=16, choices=const.AppCategory.choices, verbose_name=_('Category')
)
type = models.CharField(
max_length=16, choices=const.ApplicationTypeChoices.choices, verbose_name=_('Type')
max_length=16, choices=const.AppType.choices, verbose_name=_('Type')
)
domain = models.ForeignKey(
'assets.Domain', null=True, blank=True, related_name='applications',
@ -35,7 +190,7 @@ class Application(CommonModelMixin, OrgModelMixin):
@property
def category_remote_app(self):
return self.category == const.ApplicationCategoryChoices.remote_app.value
return self.category == const.AppCategory.remote_app.value
def get_rdp_remote_app_setting(self):
from applications.serializers.attrs import get_serializer_class_by_application_type

View File

@ -6,12 +6,12 @@ from django.utils.translation import ugettext_lazy as _
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from common.drf.serializers import MethodSerializer
from .attrs import category_serializer_classes_mapping, type_serializer_classes_mapping
from assets.serializers import SystemUserSerializer
from .. import models
from .. import const
__all__ = [
'ApplicationSerializer', 'ApplicationSerializerMixin',
'ApplicationUserSerializer', 'ApplicationUserWithAuthInfoSerializer'
'ApplicationAccountSerializer', 'ApplicationAccountSecretSerializer'
]
@ -45,16 +45,15 @@ class ApplicationSerializerMixin(serializers.Serializer):
class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSerializer):
category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category(Display)'))
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type(Dispaly)'))
category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display'))
type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display'))
class Meta:
model = models.Application
fields_mini = ['id', 'name']
fields_small = fields_mini + [
'category', 'category_display', 'type', 'type_display', 'attrs',
'date_created', 'date_updated',
'created_by', 'comment'
'category', 'category_display', 'type', 'type_display',
'attrs', 'date_created', 'date_updated', 'created_by', 'comment'
]
fields_fk = ['domain']
fields = fields_small + fields_fk
@ -68,41 +67,34 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri
return _attrs
class ApplicationUserSerializer(SystemUserSerializer):
application_name = serializers.SerializerMethodField(label=_('Application name'))
application_category = serializers.SerializerMethodField(label=_('Application category'))
application_type = serializers.SerializerMethodField(label=_('Application type'))
class ApplicationAccountSerializer(serializers.Serializer):
username = serializers.ReadOnlyField(label=_("Username"))
password = serializers.CharField(write_only=True, label=_("Password"))
systemuser = serializers.ReadOnlyField(label=_('System user'))
systemuser_display = serializers.ReadOnlyField(label=_("System user display"))
app = serializers.ReadOnlyField(label=_('App'))
uid = serializers.ReadOnlyField(label=_("Union id"))
app_name = serializers.ReadOnlyField(label=_("Application name"), read_only=True)
category = serializers.ChoiceField(label=_('Category'), choices=const.AppCategory.choices, read_only=True)
category_display = serializers.SerializerMethodField(label=_('Category display'))
type = serializers.ChoiceField(label=_('Type'), choices=const.AppType.choices, read_only=True)
type_display = serializers.SerializerMethodField(label=_('Type display'))
class Meta(SystemUserSerializer.Meta):
model = models.ApplicationUser
fields_mini = [
'id', 'application_name', 'application_category', 'application_type', 'name', 'username'
]
fields_small = fields_mini + [
'protocol', 'login_mode', 'login_mode_display', 'priority',
"username_same_with_user", 'comment',
]
fields = fields_small
extra_kwargs = {
'login_mode_display': {'label': _('Login mode display')},
'created_by': {'read_only': True},
}
category_mapper = dict(const.AppCategory.choices)
type_mapper = dict(const.AppType.choices)
@property
def application(self):
return self.context['application']
def create(self, validated_data):
pass
def get_application_name(self, obj):
return self.application.name
def update(self, instance, validated_data):
pass
def get_application_category(self, obj):
return self.application.get_category_display()
def get_category_display(self, obj):
return self.category_mapper.get(obj['category'])
def get_application_type(self, obj):
return self.application.get_type_display()
def get_type_display(self, obj):
return self.type_mapper.get(obj['type'])
class ApplicationUserWithAuthInfoSerializer(ApplicationUserSerializer):
class Meta(ApplicationUserSerializer.Meta):
fields = ApplicationUserSerializer.Meta.fields + ['password', 'token']
class ApplicationAccountSecretSerializer(ApplicationAccountSerializer):
password = serializers.CharField(write_only=False, label=_("Password"))

View File

@ -5,7 +5,7 @@ from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ObjectDoesNotExist
from common.utils import get_logger, is_uuid
from common.utils import get_logger, is_uuid, get_object_or_none
from assets.models import Asset
logger = get_logger(__file__)
@ -14,22 +14,26 @@ logger = get_logger(__file__)
__all__ = ['RemoteAppSerializer']
class CharPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
class AssetCharPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def to_internal_value(self, data):
instance = super().to_internal_value(data)
return str(instance.id)
def to_representation(self, value):
# value is instance.id
def to_representation(self, _id):
# _id 是 instance.id
if self.pk_field is not None:
return self.pk_field.to_representation(value)
return value
return self.pk_field.to_representation(_id)
# 解决删除资产后远程应用更新页面会显示资产ID的问题
asset = get_object_or_none(Asset, id=_id)
if asset:
return None
return _id
class RemoteAppSerializer(serializers.Serializer):
asset_info = serializers.SerializerMethodField()
asset = CharPrimaryKeyRelatedField(
asset = AssetCharPrimaryKeyRelatedField(
queryset=Asset.objects, required=False, label=_("Asset"), allow_null=True
)
path = serializers.CharField(

View File

@ -14,9 +14,9 @@ __all__ = [
# ---------------------------------------------------
category_serializer_classes_mapping = {
const.ApplicationCategoryChoices.db.value: application_category.DBSerializer,
const.ApplicationCategoryChoices.remote_app.value: application_category.RemoteAppSerializer,
const.ApplicationCategoryChoices.cloud.value: application_category.CloudSerializer,
const.AppCategory.db.value: application_category.DBSerializer,
const.AppCategory.remote_app.value: application_category.RemoteAppSerializer,
const.AppCategory.cloud.value: application_category.CloudSerializer,
}
# define `attrs` field `type serializers mapping`
@ -24,17 +24,17 @@ category_serializer_classes_mapping = {
type_serializer_classes_mapping = {
# db
const.ApplicationTypeChoices.mysql.value: application_type.MySQLSerializer,
const.ApplicationTypeChoices.mariadb.value: application_type.MariaDBSerializer,
const.ApplicationTypeChoices.oracle.value: application_type.OracleSerializer,
const.ApplicationTypeChoices.pgsql.value: application_type.PostgreSerializer,
const.AppType.mysql.value: application_type.MySQLSerializer,
const.AppType.mariadb.value: application_type.MariaDBSerializer,
const.AppType.oracle.value: application_type.OracleSerializer,
const.AppType.pgsql.value: application_type.PostgreSerializer,
# remote-app
const.ApplicationTypeChoices.chrome.value: application_type.ChromeSerializer,
const.ApplicationTypeChoices.mysql_workbench.value: application_type.MySQLWorkbenchSerializer,
const.ApplicationTypeChoices.vmware_client.value: application_type.VMwareClientSerializer,
const.ApplicationTypeChoices.custom.value: application_type.CustomSerializer,
const.AppType.chrome.value: application_type.ChromeSerializer,
const.AppType.mysql_workbench.value: application_type.MySQLWorkbenchSerializer,
const.AppType.vmware_client.value: application_type.VMwareClientSerializer,
const.AppType.custom.value: application_type.CustomSerializer,
# cloud
const.ApplicationTypeChoices.k8s.value: application_type.K8SSerializer
const.AppType.k8s.value: application_type.K8SSerializer
}

View File

@ -10,12 +10,14 @@ app_name = 'applications'
router = BulkRouter()
router.register(r'applications', api.ApplicationViewSet, 'application')
router.register(r'accounts', api.ApplicationAccountViewSet, 'application-account')
router.register(r'account-secrets', api.ApplicationAccountSecretViewSet, 'application-account-secret')
urlpatterns = [
path('remote-apps/<uuid:pk>/connection-info/', api.RemoteAppConnectionInfoApi.as_view(), name='remote-app-connection-info'),
path('application-users/', api.ApplicationUserListApi.as_view(), name='application-user'),
path('application-user-auth-infos/', api.ApplicationUserAuthInfoListApi.as_view(), name='application-user-auth-info')
# path('accounts/', api.ApplicationAccountViewSet.as_view(), name='application-account'),
# path('account-secrets/', api.ApplicationAccountSecretViewSet.as_view(), name='application-account-secret')
]

View File

@ -1,15 +1,15 @@
from django.db.models import F, Q
from django.conf import settings
from rest_framework.decorators import action
from django_filters import rest_framework as filters
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from rest_framework.generics import CreateAPIView
from orgs.mixins.api import OrgBulkModelViewSet
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, NeedMFAVerify
from common.drf.filters import BaseFilterSet
from ..tasks.account_connectivity import test_accounts_connectivity_manual
from ..models import AuthBook
from ..models import AuthBook, Node
from .. import serializers
__all__ = ['AccountViewSet', 'AccountSecretsViewSet', 'AccountTaskCreateAPI']
@ -19,11 +19,13 @@ class AccountFilterSet(BaseFilterSet):
username = filters.CharFilter(method='do_nothing')
ip = filters.CharFilter(field_name='ip', lookup_expr='exact')
hostname = filters.CharFilter(field_name='hostname', lookup_expr='exact')
node = filters.CharFilter(method='do_nothing')
@property
def qs(self):
qs = super().qs
qs = self.filter_username(qs)
qs = self.filter_node(qs)
return qs
def filter_username(self, qs):
@ -33,6 +35,16 @@ class AccountFilterSet(BaseFilterSet):
qs = qs.filter(Q(username=username) | Q(systemuser__username=username)).distinct()
return qs
def filter_node(self, qs):
node_id = self.get_query_param('node')
if not node_id:
return qs
node = get_object_or_404(Node, pk=node_id)
node_ids = node.get_children(with_self=True).values_list('id', flat=True)
node_ids = list(node_ids)
qs = qs.filter(asset__nodes__in=node_ids)
return qs
class Meta:
model = AuthBook
fields = [
@ -74,11 +86,6 @@ class AccountSecretsViewSet(AccountViewSet):
permission_classes = (IsOrgAdmin, NeedMFAVerify)
http_method_names = ['get']
def get_permissions(self):
if not settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [IsOrgAdminOrAppUser]
return super().get_permissions()
class AccountTaskCreateAPI(CreateAPIView):
permission_classes = (IsOrgAdminOrAppUser,)

View File

@ -9,10 +9,11 @@ from common.utils import get_logger, get_object_or_none
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser
from orgs.mixins.api import OrgBulkModelViewSet
from orgs.mixins import generics
from ..models import Asset, Node, Platform
from ..models import Asset, Node, Platform, SystemUser
from .. import serializers
from ..tasks import (
update_assets_hardware_info_manual, test_assets_connectivity_manual
update_assets_hardware_info_manual, test_assets_connectivity_manual,
test_system_users_connectivity_a_asset, push_system_users_a_asset
)
from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend
@ -94,21 +95,27 @@ class AssetPlatformViewSet(ModelViewSet):
class AssetsTaskMixin:
def perform_assets_task(self, serializer):
data = serializer.validated_data
assets = data['assets']
action = data['action']
assets = data.get('assets', [])
if action == "refresh":
task = update_assets_hardware_info_manual.delay(assets)
else:
# action == 'test':
task = test_assets_connectivity_manual.delay(assets)
return task
def perform_create(self, serializer):
task = self.perform_assets_task(serializer)
self.set_task_to_serializer_data(serializer, task)
def set_task_to_serializer_data(self, serializer, task):
data = getattr(serializer, '_data', {})
data["task"] = task.id
setattr(serializer, '_data', data)
def perform_create(self, serializer):
self.perform_assets_task(serializer)
class AssetTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView):
model = Asset
@ -117,13 +124,37 @@ class AssetTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView):
def create(self, request, *args, **kwargs):
pk = self.kwargs.get('pk')
request.data['asset'] = pk
request.data['assets'] = [pk]
return super().create(request, *args, **kwargs)
def perform_asset_task(self, serializer):
data = serializer.validated_data
action = data['action']
if action not in ['push_system_user', 'test_system_user']:
return
asset = data['asset']
system_users = data.get('system_users')
if not system_users:
system_users = asset.get_all_systemusers()
if action == 'push_system_user':
task = push_system_users_a_asset.delay(system_users, asset=asset)
elif action == 'test_system_user':
task = test_system_users_connectivity_a_asset.delay(system_users, asset=asset)
else:
task = None
return task
def perform_create(self, serializer):
task = self.perform_asset_task(serializer)
if not task:
task = self.perform_assets_task(serializer)
self.set_task_to_serializer_data(serializer, task)
class AssetsTaskCreateApi(AssetsTaskMixin, generics.CreateAPIView):
model = Asset
serializer_class = serializers.AssetTaskSerializer
serializer_class = serializers.AssetsTaskSerializer
permission_classes = (IsOrgAdmin,)

View File

@ -33,7 +33,7 @@ class GatewayViewSet(OrgBulkModelViewSet):
model = Gateway
filterset_fields = ("domain__name", "name", "username", "ip", "domain")
search_fields = ("domain__name", "name", "username", "ip")
permission_classes = (IsOrgAdmin,)
permission_classes = (IsOrgAdminOrAppUser,)
serializer_class = serializers.GatewaySerializer

View File

@ -333,7 +333,7 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
'iconSkin': icon_skin,
'meta': {
'type': 'asset',
'asset': {
'data': {
'id': self.id,
'hostname': self.hostname,
'ip': self.ip,
@ -345,6 +345,13 @@ class Asset(AbsConnectivity, ProtocolsMixin, NodesRelationMixin, OrgModelMixin):
tree_node = TreeNode(**data)
return tree_node
def get_all_systemusers(self):
from .user import SystemUser
system_user_ids = SystemUser.assets.through.objects.filter(asset=self)\
.values_list('systemuser_id', flat=True)
system_users = SystemUser.objects.filter(id__in=system_user_ids)
return system_users
class Meta:
unique_together = [('org_id', 'hostname')]
verbose_name = _("Asset")

View File

@ -67,7 +67,10 @@ class AuthMixin:
if self.public_key:
public_key = self.public_key
elif self.private_key:
public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password)
try:
public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password)
except IOError as e:
return str(e)
else:
return ''

View File

@ -3,17 +3,19 @@
import socket
import uuid
import random
import re
from django.core.cache import cache
import paramiko
from django.db import models
from django.db.models import TextChoices
from django.utils.translation import ugettext_lazy as _
from common.utils.strings import no_special_chars
from common.utils import get_logger
from orgs.mixins.models import OrgModelMixin
from .base import BaseUser
logger = get_logger(__file__)
__all__ = ['Domain', 'Gateway']
@ -40,10 +42,19 @@ class Domain(OrgModelMixin):
return self.gateway_set.filter(is_active=True)
def random_gateway(self):
return random.choice(self.gateways)
gateways = [gw for gw in self.gateways if gw.is_connective]
if gateways:
return random.choice(gateways)
else:
logger.warn(f'Gateway all bad. domain={self}, gateway_num={len(self.gateways)}.')
return random.choice(self.gateways)
class Gateway(BaseUser):
UNCONNECTIVE_KEY_TMPL = 'asset_unconnective_gateway_{}'
UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL = 'asset_unconnective_gateway_silence_period_{}'
UNCONNECTIVE_SILENCE_PERIOD_BEGIN_VALUE = 60 * 5
class Protocol(TextChoices):
ssh = 'ssh', 'SSH'
@ -61,11 +72,40 @@ class Gateway(BaseUser):
unique_together = [('name', 'org_id')]
verbose_name = _("Gateway")
def set_unconnective(self):
unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id)
unconnective_silence_period_key = self.UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL.format(self.id)
unconnective_silence_period = cache.get(unconnective_silence_period_key,
self.UNCONNECTIVE_SILENCE_PERIOD_BEGIN_VALUE)
cache.set(unconnective_silence_period_key, unconnective_silence_period * 2)
cache.set(unconnective_key, unconnective_silence_period, unconnective_silence_period)
def set_connective(self):
unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id)
unconnective_silence_period_key = self.UNCONNECTIVE_SILENCE_PERIOD_KEY_TMPL.format(self.id)
cache.delete(unconnective_key)
cache.delete(unconnective_silence_period_key)
def get_is_unconnective(self):
unconnective_key = self.UNCONNECTIVE_KEY_TMPL.format(self.id)
return cache.get(unconnective_key, False)
@property
def is_connective(self):
return not self.get_is_unconnective()
@is_connective.setter
def is_connective(self, value):
if value:
self.set_connective()
else:
self.set_unconnective()
def test_connective(self, local_port=None):
if local_port is None:
local_port = self.port
if self.password and not no_special_chars(self.password):
return False, _("Password should not contains special characters")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
@ -82,7 +122,14 @@ class Gateway(BaseUser):
paramiko.SSHException,
paramiko.ssh_exception.NoValidConnectionsError,
socket.gaierror) as e:
return False, str(e)
err = str(e)
if err.startswith('[Errno None] Unable to connect to port'):
err = _('Unable to connect to port {port} on {ip}')
err = err.format(port=self.port, ip=self.ip)
elif err == 'Authentication failed.':
err = _('Authentication failed')
self.is_connective = False
return False, err
try:
sock = proxy.get_transport().open_channel(
@ -96,7 +143,9 @@ class Gateway(BaseUser):
timeout=5)
except (paramiko.SSHException, paramiko.ssh_exception.SSHException,
paramiko.AuthenticationException, TimeoutError) as e:
self.is_connective = False
return False, str(e)
finally:
client.close()
self.is_connective = True
return True, None

View File

@ -608,7 +608,7 @@ class Node(OrgModelMixin, SomeNodesMixin, FamilyMixin, NodeAssetsMixin):
'isParent': True,
'open': self.is_org_root(),
'meta': {
'node': {
'data': {
"id": self.id,
"name": self.name,
"value": self.value,

View File

@ -60,10 +60,10 @@ class ProtocolMixin:
@classmethod
def get_protocol_by_application_type(cls, app_type):
from applications.const import ApplicationTypeChoices
from applications.const import AppType
if app_type in cls.APPLICATION_CATEGORY_PROTOCOLS:
protocol = app_type
elif app_type in ApplicationTypeChoices.remote_app_types():
elif app_type in AppType.remote_app_types():
protocol = cls.Protocol.rdp
else:
protocol = None

View File

@ -5,6 +5,7 @@ from assets.models import AuthBook
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from .base import AuthSerializerMixin
from .utils import validate_password_contains_left_double_curly_bracket
class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
@ -21,10 +22,15 @@ class AccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
fields = fields_small + fields_fk
extra_kwargs = {
'username': {'required': True},
'password': {'write_only': True},
'password': {
'write_only': True,
"validators": [validate_password_contains_left_double_curly_bracket]
},
'private_key': {'write_only': True},
'public_key': {'write_only': True},
'systemuser_display': {'label': _('System user display')}
}
ref_name = 'AssetAccountSerializer'
@classmethod
def setup_eager_loading(cls, queryset):

View File

@ -10,7 +10,7 @@ from ..models import Asset, Node, Platform, SystemUser
__all__ = [
'AssetSerializer', 'AssetSimpleSerializer',
'ProtocolsField', 'PlatformSerializer',
'AssetTaskSerializer',
'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField'
]
@ -183,7 +183,7 @@ class AssetSimpleSerializer(serializers.ModelSerializer):
fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified']
class AssetTaskSerializer(serializers.Serializer):
class AssetsTaskSerializer(serializers.Serializer):
ACTION_CHOICES = (
('refresh', 'refresh'),
('test', 'test'),
@ -193,3 +193,16 @@ class AssetTaskSerializer(serializers.Serializer):
assets = serializers.PrimaryKeyRelatedField(
queryset=Asset.objects, required=False, allow_empty=True, many=True
)
class AssetTaskSerializer(AssetsTaskSerializer):
ACTION_CHOICES = tuple(list(AssetsTaskSerializer.ACTION_CHOICES) + [
('push_system_user', 'push_system_user'),
('test_system_user', 'test_system_user')
])
asset = serializers.PrimaryKeyRelatedField(
queryset=Asset.objects, required=False, allow_empty=True, many=False
)
system_users = serializers.PrimaryKeyRelatedField(
queryset=SystemUser.objects, required=False, allow_empty=True, many=True
)

View File

@ -4,14 +4,13 @@ from rest_framework import serializers
from django.utils.translation import ugettext_lazy as _
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from common.validators import NoSpecialChars
from ..models import Domain, Gateway
from .base import AuthSerializerMixin
class DomainSerializer(BulkOrgResourceModelSerializer):
asset_count = serializers.SerializerMethodField(label=_('Assets count'))
application_count = serializers.SerializerMethodField(label=_('Applications count'))
asset_count = serializers.SerializerMethodField(label=_('Assets amount'))
application_count = serializers.SerializerMethodField(label=_('Applications amount'))
gateway_count = serializers.SerializerMethodField(label=_('Gateways count'))
class Meta:
@ -43,6 +42,8 @@ class DomainSerializer(BulkOrgResourceModelSerializer):
class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
is_connective = serializers.BooleanField(required=False)
class Meta:
model = Gateway
fields_mini = ['id', 'name']
@ -51,14 +52,14 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
]
fields_small = fields_mini + fields_write_only + [
'username', 'ip', 'port', 'protocol',
'is_active',
'is_active', 'is_connective',
'date_created', 'date_updated',
'created_by', 'comment',
]
fields_fk = ['domain']
fields = fields_small + fields_fk
extra_kwargs = {
'password': {'write_only': True, 'validators': [NoSpecialChars()]},
'password': {'write_only': True},
'private_key': {"write_only": True},
'public_key': {"write_only": True},
}
@ -67,7 +68,7 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
class GatewayWithAuthSerializer(GatewaySerializer):
class Meta(GatewaySerializer.Meta):
extra_kwargs = {
'password': {'write_only': False, 'validators': [NoSpecialChars()]},
'password': {'write_only': False},
'private_key': {"write_only": False},
'public_key': {"write_only": False},
}

View File

@ -6,6 +6,7 @@ from common.mixins.serializers import BulkSerializerMixin
from common.utils import ssh_pubkey_gen
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
from ..models import SystemUser, Asset
from .utils import validate_password_contains_left_double_curly_bracket
from .base import AuthSerializerMixin
__all__ = [
@ -40,7 +41,10 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
fields_m2m = ['cmd_filters', 'assets_amount']
fields = fields_small + fields_m2m
extra_kwargs = {
'password': {"write_only": True},
'password': {
"write_only": True,
"validators": [validate_password_contains_left_double_curly_bracket]
},
'public_key': {"write_only": True},
'private_key': {"write_only": True},
'token': {"write_only": True},

View File

@ -0,0 +1,9 @@
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
def validate_password_contains_left_double_curly_bracket(password):
# validate password contains left double curly bracket
# check password not contains `{{`
if '{{' in password:
raise serializers.ValidationError(_('Password can not contains `{{` '))

View File

@ -60,9 +60,12 @@ def parse_windows_result_to_users(result):
task_result.pop()
for line in task_result:
user = space.split(line)
if user[0]:
users[user[0]] = {}
username_list = space.split(line)
# such as: ['Admini', 'appadm', 'DefaultAccount', '']
for username in username_list:
if not username:
continue
users[username] = {}
return users

View File

@ -17,6 +17,7 @@ logger = get_logger(__file__)
__all__ = [
'push_system_user_util', 'push_system_user_to_assets',
'push_system_user_to_assets_manual', 'push_system_user_a_asset_manual',
'push_system_users_a_asset'
]
@ -280,14 +281,21 @@ def push_system_user_a_asset_manual(system_user, asset, username=None):
"""
将系统用户推送到一个资产上
"""
if username is None:
username = system_user.username
# if username is None:
# username = system_user.username
task_name = _("Push system users to asset: {}({}) => {}").format(
system_user.name, username, asset
)
return push_system_user_util(system_user, [asset], task_name=task_name, username=username)
@shared_task(queue="ansible")
@tmp_to_root_org()
def push_system_users_a_asset(system_users, asset):
for system_user in system_users:
push_system_user_a_asset_manual(system_user, asset)
@shared_task(queue="ansible")
@tmp_to_root_org()
def push_system_user_to_assets(system_user_id, asset_ids, username=None):

View File

@ -18,6 +18,7 @@ logger = get_logger(__name__)
__all__ = [
'test_system_user_connectivity_util', 'test_system_user_connectivity_manual',
'test_system_user_connectivity_period', 'test_system_user_connectivity_a_asset',
'test_system_users_connectivity_a_asset'
]
@ -131,6 +132,12 @@ def test_system_user_connectivity_a_asset(system_user, asset):
test_system_user_connectivity_util(system_user, [asset], task_name)
@shared_task(queue="ansible")
def test_system_users_connectivity_a_asset(system_users, asset):
for system_user in system_users:
test_system_user_connectivity_a_asset(system_user, asset)
@shared_task(queue="ansible")
def test_system_user_connectivity_period():
if not const.PERIOD_TASK_ENABLED:

View File

@ -5,7 +5,7 @@ from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from orgs.mixins.models import OrgModelMixin
from orgs.mixins.models import OrgModelMixin, Organization
from orgs.utils import current_org
__all__ = [
@ -63,6 +63,11 @@ class OperateLog(OrgModelMixin):
def __str__(self):
return "<{}> {} <{}>".format(self.user, self.action, self.resource)
def save(self, *args, **kwargs):
if current_org.is_root() and not self.org_id:
self.org_id = Organization.ROOT_ID
return super(OperateLog, self).save(*args, **kwargs)
class PasswordChangeLog(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#
from django.db.models.signals import post_save, post_delete
from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from django.conf import settings
from django.db import transaction
@ -11,6 +11,8 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework.renderers import JSONRenderer
from rest_framework.request import Request
from assets.models import Asset
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
from jumpserver.utils import current_request
from common.utils import get_request_ip, get_logger, get_syslogger
from users.models import User
@ -20,6 +22,9 @@ from terminal.models import Session, Command
from common.utils.encode import model_to_json
from .utils import write_login_log
from . import models
from .models import OperateLog
from orgs.utils import current_org
from perms.models import AssetPermission, ApplicationPermission
logger = get_logger(__name__)
sys_logger = get_syslogger(__name__)
@ -90,6 +95,119 @@ def create_operate_log(action, sender, resource):
logger.error("Create operate log error: {}".format(e))
M2M_NEED_RECORD = {
'OrganizationMember': (
_('User and Organization'),
_('{User} *JOINED* {Organization}'),
_('{User} *LEFT* {Organization}')
),
User.groups.through._meta.object_name: (
_('User and Group'),
_('{User} *JOINED* {UserGroup}'),
_('{User} *LEFT* {UserGroup}')
),
Asset.nodes.through._meta.object_name: (
_('Node and Asset'),
_('{Node} *ADD* {Asset}'),
_('{Node} *REMOVE* {Asset}')
),
AssetPermission.users.through._meta.object_name: (
_('User asset permissions'),
_('{AssetPermission} *ADD* {User}'),
_('{AssetPermission} *REMOVE* {User}'),
),
AssetPermission.user_groups.through._meta.object_name: (
_('User group asset permissions'),
_('{AssetPermission} *ADD* {UserGroup}'),
_('{AssetPermission} *REMOVE* {UserGroup}'),
),
AssetPermission.assets.through._meta.object_name: (
_('Asset permission'),
_('{AssetPermission} *ADD* {Asset}'),
_('{AssetPermission} *REMOVE* {Asset}'),
),
AssetPermission.nodes.through._meta.object_name: (
_('Node permission'),
_('{AssetPermission} *ADD* {Node}'),
_('{AssetPermission} *REMOVE* {Node}'),
),
AssetPermission.system_users.through._meta.object_name: (
_('Asset permission and SystemUser'),
_('{AssetPermission} *ADD* {SystemUser}'),
_('{AssetPermission} *REMOVE* {SystemUser}'),
),
ApplicationPermission.users.through._meta.object_name: (
_('User application permissions'),
_('{ApplicationPermission} *ADD* {User}'),
_('{ApplicationPermission} *REMOVE* {User}'),
),
ApplicationPermission.user_groups.through._meta.object_name: (
_('User group application permissions'),
_('{ApplicationPermission} *ADD* {UserGroup}'),
_('{ApplicationPermission} *REMOVE* {UserGroup}'),
),
ApplicationPermission.applications.through._meta.object_name: (
_('Application permission'),
_('{ApplicationPermission} *ADD* {Application}'),
_('{ApplicationPermission} *REMOVE* {Application}'),
),
ApplicationPermission.system_users.through._meta.object_name: (
_('Application permission and SystemUser'),
_('{ApplicationPermission} *ADD* {SystemUser}'),
_('{ApplicationPermission} *REMOVE* {SystemUser}'),
),
}
M2M_ACTION = {
POST_ADD: 'add',
POST_REMOVE: 'remove',
POST_CLEAR: 'remove',
}
@receiver(m2m_changed)
def on_m2m_changed(sender, action, instance, reverse, model, pk_set, **kwargs):
if action not in M2M_ACTION:
return
user = current_request.user if current_request else None
if not user or not user.is_authenticated:
return
sender_name = sender._meta.object_name
if sender_name in M2M_NEED_RECORD:
action = M2M_ACTION[action]
org_id = current_org.id
remote_addr = get_request_ip(current_request)
user = str(user)
resource_type, resource_tmpl_add, resource_tmpl_remove = M2M_NEED_RECORD[sender_name]
if action == 'add':
resource_tmpl = resource_tmpl_add
elif action == 'remove':
resource_tmpl = resource_tmpl_remove
to_create = []
objs = model.objects.filter(pk__in=pk_set)
instance_name = instance._meta.object_name
instance_value = str(instance)
model_name = model._meta.object_name
for obj in objs:
resource = resource_tmpl.format(**{
instance_name: instance_value,
model_name: str(obj)
})[:128] # `resource` 字段只有 128 个字符长 😔
to_create.append(OperateLog(
user=user, action=action, resource_type=resource_type,
resource=resource, remote_addr=remote_addr, org_id=org_id
))
OperateLog.objects.bulk_create(to_create)
@receiver(post_save)
def on_object_created_or_update(sender, instance=None, created=False, update_fields=None, **kwargs):
# last_login 改变是最后登录日期, 每次登录都会改变

View File

@ -9,4 +9,5 @@ from .login_confirm import *
from .sso import *
from .wecom import *
from .dingtalk import *
from .feishu import *
from .password import *

View File

@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
#
import urllib.parse
import json
import base64
from typing import Callable
from django.conf import settings
from django.core.cache import cache
@ -8,6 +11,7 @@ from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from django.utils.translation import ugettext as _
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework.viewsets import GenericViewSet
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@ -17,90 +21,40 @@ from authentication.signals import post_auth_failed, post_auth_success
from common.utils import get_logger, random_string
from common.drf.api import SerializerMixin
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
from orgs.mixins.api import RootOrgViewMixin
from common.http import is_true
from ..serializers import (
ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
RDPFileSerializer
)
logger = get_logger(__name__)
__all__ = ['UserConnectionTokenViewSet']
class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewSet):
permission_classes = (IsSuperUserOrAppUser,)
serializer_classes = {
'default': ConnectionTokenSerializer,
'get_secret_detail': ConnectionTokenSecretSerializer,
'get_rdp_file': RDPFileSerializer
}
CACHE_KEY_PREFIX = 'CONNECTION_TOKEN_{}'
@staticmethod
def check_resource_permission(user, asset, application, system_user):
from perms.utils.asset import has_asset_system_permission
from perms.utils.application import has_application_system_permission
if asset and not has_asset_system_permission(user, asset, system_user):
error = f'User not has this asset and system user permission: ' \
f'user={user.id} system_user={system_user.id} asset={asset.id}'
raise PermissionDenied(error)
if application and not has_application_system_permission(user, application, system_user):
error = f'User not has this application and system user permission: ' \
f'user={user.id} system_user={system_user.id} application={application.id}'
raise PermissionDenied(error)
return True
def create_token(self, user, asset, application, system_user, ttl=5*60):
if not self.request.user.is_superuser and user != self.request.user:
raise PermissionDenied('Only super user can create user token')
self.check_resource_permission(user, asset, application, system_user)
token = random_string(36)
value = {
'user': str(user.id),
'username': user.username,
'system_user': str(system_user.id),
'system_user_name': system_user.name
}
if asset:
value.update({
'type': 'asset',
'asset': str(asset.id),
'hostname': asset.hostname,
})
elif application:
value.update({
'type': 'application',
'application': application.id,
'application_name': str(application)
})
key = self.CACHE_KEY_PREFIX.format(token)
cache.set(key, value, timeout=ttl)
return token
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
class ClientProtocolMixin:
request: Request
get_serializer: Callable
create_token: Callable
def get_request_resource(self, serializer):
asset = serializer.validated_data.get('asset')
application = serializer.validated_data.get('application')
system_user = serializer.validated_data['system_user']
user = serializer.validated_data.get('user')
token = self.create_token(user, asset, application, system_user)
return Response({"token": token}, status=201)
@action(methods=['POST', 'GET'], detail=False, url_path='rdp/file', permission_classes=[IsValidUser])
def get_rdp_file(self, request, *args, **kwargs):
user = serializer.validated_data.get('user')
if not user or not self.request.user.is_superuser:
user = self.request.user
return asset, application, system_user, user
def get_rdp_file_content(self, serializer):
options = {
'full address:s': '',
'username:s': '',
'screen mode id:i': '0',
# 'screen mode id:i': '1',
# 'desktopwidth:i': '1280',
# 'desktopheight:i': '800',
'use multimon:i': '1',
'use multimon:i': '0',
'session bpp:i': '32',
'audiomode:i': '0',
'disable wallpaper:i': '0',
@ -125,24 +79,17 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS
# 'remoteapplicationname:s': 'Firefox',
# 'remoteapplicationcmdline:s': '',
}
if self.request.method == 'GET':
data = self.request.query_params
else:
data = request.data
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
asset = serializer.validated_data.get('asset')
application = serializer.validated_data.get('application')
system_user = serializer.validated_data['system_user']
height = serializer.validated_data.get('height')
width = serializer.validated_data.get('width')
user = request.user
asset, application, system_user, user = self.get_request_resource(serializer)
height = self.request.query_params.get('height')
width = self.request.query_params.get('width')
full_screen = is_true(self.request.query_params.get('full_screen'))
token = self.create_token(user, asset, application, system_user)
options['screen mode id:i'] = '2' if full_screen else '1'
address = settings.TERMINAL_RDP_ADDR
if not address or address == 'localhost:3389':
address = request.get_host().split(':')[0] + ':3389'
address = self.request.get_host().split(':')[0] + ':3389'
options['full address:s'] = address
options['username:s'] = '{}|{}'.format(user.username, token)
if system_user.ad_domain:
@ -152,21 +99,73 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS
options['desktopheight:i'] = height
else:
options['smart sizing:i'] = '1'
data = ''
content = ''
for k, v in options.items():
data += f'{k}:{v}\n'
content += f'{k}:{v}\n'
if asset:
name = asset.hostname
elif application:
name = application.name
else:
name = '*'
return name, content
@action(methods=['POST', 'GET'], detail=False, url_path='rdp/file', permission_classes=[IsValidUser])
def get_rdp_file(self, request, *args, **kwargs):
if self.request.method == 'GET':
data = self.request.query_params
else:
data = self.request.data
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
name, data = self.get_rdp_file_content(serializer)
response = HttpResponse(data, content_type='application/octet-stream')
filename = "{}-{}-jumpserver.rdp".format(user.username, name)
filename = "{}-{}-jumpserver.rdp".format(self.request.user.username, name)
filename = urllib.parse.quote(filename)
response['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % filename
return response
def get_valid_serializer(self):
if self.request.method == 'GET':
data = self.request.query_params
else:
data = self.request.data
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
return serializer
def get_client_protocol_data(self, serializer):
asset, application, system_user, user = self.get_request_resource(serializer)
protocol = system_user.protocol
if protocol == 'rdp':
name, config = self.get_rdp_file_content(serializer)
elif protocol == 'vnc':
raise HttpResponse(status=404, data={"error": "VNC not support"})
else:
config = 'ssh://system_user@asset@user@jumpserver-ssh'
data = {
"protocol": system_user.protocol,
"username": user.username,
"config": config
}
return data
@action(methods=['POST', 'GET'], detail=False, url_path='client-url', permission_classes=[IsValidUser])
def get_client_protocol_url(self, request, *args, **kwargs):
serializer = self.get_valid_serializer()
protocol_data = self.get_client_protocol_data(serializer)
protocol_data = base64.b64encode(json.dumps(protocol_data).encode()).decode()
data = {
'url': 'jms://{}'.format(protocol_data),
}
return Response(data=data)
class SecretDetailMixin:
valid_token: Callable
request: Request
get_serializer: Callable
@staticmethod
def _get_application_secret_detail(application):
from perms.models import Action
@ -212,6 +211,100 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS
'actions': actions,
}
@action(methods=['POST'], detail=False, permission_classes=[IsSuperUserOrAppUser], url_path='secret-info/detail')
def get_secret_detail(self, request, *args, **kwargs):
token = request.data.get('token', '')
try:
value, user, system_user, asset, app, expired_at = self.valid_token(token)
except serializers.ValidationError as e:
post_auth_failed.send(
sender=self.__class__, username='', request=self.request,
reason=_('Invalid token')
)
raise e
data = dict(user=user, system_user=system_user, expired_at=expired_at)
if asset:
asset_detail = self._get_asset_secret_detail(asset, user=user, system_user=system_user)
system_user.load_asset_more_auth(asset.id, user.username, user.id)
data['type'] = 'asset'
data.update(asset_detail)
else:
app_detail = self._get_application_secret_detail(app)
system_user.load_app_more_auth(app.id, user.id)
data['type'] = 'application'
data.update(app_detail)
self.request.session['auth_backend'] = settings.AUTH_BACKEND_AUTH_TOKEN
post_auth_success.send(sender=self.__class__, user=user, request=self.request, login_type='T')
serializer = self.get_serializer(data)
return Response(data=serializer.data, status=200)
class UserConnectionTokenViewSet(
RootOrgViewMixin, SerializerMixin, ClientProtocolMixin,
SecretDetailMixin, GenericViewSet
):
permission_classes = (IsSuperUserOrAppUser,)
serializer_classes = {
'default': ConnectionTokenSerializer,
'get_secret_detail': ConnectionTokenSecretSerializer,
}
CACHE_KEY_PREFIX = 'CONNECTION_TOKEN_{}'
@staticmethod
def check_resource_permission(user, asset, application, system_user):
from perms.utils.asset import has_asset_system_permission
from perms.utils.application import has_application_system_permission
if asset and not has_asset_system_permission(user, asset, system_user):
error = f'User not has this asset and system user permission: ' \
f'user={user.id} system_user={system_user.id} asset={asset.id}'
raise PermissionDenied(error)
if application and not has_application_system_permission(user, application, system_user):
error = f'User not has this application and system user permission: ' \
f'user={user.id} system_user={system_user.id} application={application.id}'
raise PermissionDenied(error)
return True
def create_token(self, user, asset, application, system_user, ttl=5 * 60):
if not self.request.user.is_superuser and user != self.request.user:
raise PermissionDenied('Only super user can create user token')
self.check_resource_permission(user, asset, application, system_user)
token = random_string(36)
value = {
'user': str(user.id),
'username': user.username,
'system_user': str(system_user.id),
'system_user_name': system_user.name
}
if asset:
value.update({
'type': 'asset',
'asset': str(asset.id),
'hostname': asset.hostname,
})
elif application:
value.update({
'type': 'application',
'application': application.id,
'application_name': str(application)
})
key = self.CACHE_KEY_PREFIX.format(token)
cache.set(key, value, timeout=ttl)
return token
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
asset, application, system_user, user = self.get_request_resource(serializer)
token = self.create_token(user, asset, application, system_user)
return Response({"token": token}, status=201)
def valid_token(self, token):
from users.models import User
from assets.models import SystemUser, Asset
@ -244,39 +337,8 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin, GenericViewS
if not has_perm:
raise serializers.ValidationError('Permission expired or invalid')
return value, user, system_user, asset, app, expired_at
@action(methods=['POST'], detail=False, permission_classes=[IsSuperUserOrAppUser], url_path='secret-info/detail')
def get_secret_detail(self, request, *args, **kwargs):
token = request.data.get('token', '')
try:
value, user, system_user, asset, app, expired_at = self.valid_token(token)
except serializers.ValidationError as e:
post_auth_failed.send(
sender=self.__class__, username='', request=self.request,
reason=_('Invalid token')
)
raise e
data = dict(user=user, system_user=system_user, expired_at=expired_at)
if asset:
asset_detail = self._get_asset_secret_detail(asset, user=user, system_user=system_user)
system_user.load_asset_more_auth(asset.id, user.username, user.id)
data['type'] = 'asset'
data.update(asset_detail)
else:
app_detail = self._get_application_secret_detail(app)
system_user.load_app_more_auth(app.id, user.id)
data['type'] = 'application'
data.update(app_detail)
self.request.session['auth_backend'] = settings.AUTH_BACKEND_AUTH_TOKEN
post_auth_success.send(sender=self.__class__, user=user, request=self.request, login_type='T')
serializer = self.get_serializer(data)
return Response(data=serializer.data, status=200)
def get_permissions(self):
if self.action in ["create", "get_rdp_file"]:
if self.request.data.get('user', None):

View File

@ -0,0 +1,45 @@
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from users.permissions import IsAuthPasswdTimeValid
from users.models import User
from common.utils import get_logger
from common.permissions import IsOrgAdmin
from common.mixins.api import RoleUserMixin, RoleAdminMixin
from authentication import errors
logger = get_logger(__file__)
class FeiShuQRUnBindBase(APIView):
user: User
def post(self, request: Request, **kwargs):
user = self.user
if not user.feishu_id:
raise errors.FeiShuNotBound
user.feishu_id = None
user.save()
return Response()
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
permission_classes = (IsAuthPasswdTimeValid,)
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):
user_id_url_kwarg = 'user_id'
permission_classes = (IsOrgAdmin,)
class FeiShuEventSubscriptionCallback(APIView):
"""
# https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM
"""
permission_classes = ()
def post(self, request: Request, *args, **kwargs):
return Response(data=request.data)

View File

@ -11,7 +11,7 @@ from rest_framework.permissions import AllowAny
from common.utils.timezone import utcnow
from common.const.http import POST, GET
from common.drf.api import JmsGenericViewSet
from common.drf.api import JMSGenericViewSet
from common.drf.serializers import EmptySerializer
from common.permissions import IsSuperUser
from common.utils import reverse
@ -26,7 +26,7 @@ NEXT_URL = 'next'
AUTH_KEY = 'authkey'
class SSOViewSet(AuthMixin, JmsGenericViewSet):
class SSOViewSet(AuthMixin, JMSGenericViewSet):
queryset = SSOToken.objects.all()
serializer_classes = {
'login_url': SSOTokenSerializer,

View File

@ -240,6 +240,15 @@ class DingTalkAuthentication(JMSModelBackend):
pass
class FeiShuAuthentication(JMSModelBackend):
"""
什么也不做呀😺
"""
def authenticate(self, request, **kwargs):
pass
class AuthorizationTokenAuthentication(JMSModelBackend):
"""
什么也不做呀😺

View File

@ -315,6 +315,11 @@ class DingTalkNotBound(JMSException):
default_detail = 'DingTalk is not bound'
class FeiShuNotBound(JMSException):
default_code = 'feishu_not_bound'
default_detail = 'FeiShu is not bound'
class PasswdInvalid(JMSException):
default_code = 'passwd_invalid'
default_detail = _('Your password is invalid')

View File

@ -198,7 +198,3 @@ class ConnectionTokenSecretSerializer(serializers.Serializer):
actions = ActionsField()
expired_at = serializers.IntegerField()
class RDPFileSerializer(ConnectionTokenSerializer):
width = serializers.IntegerField(allow_null=True, max_value=3112, min_value=100, required=False)
height = serializers.IntegerField(allow_null=True, max_value=4096, min_value=100, required=False)

View File

@ -191,7 +191,7 @@
</div>
<div>
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK %}
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK or AUTH_FEISHU %}
<div class="hr-line-dashed"></div>
<div style="display: inline-block; float: left">
<b class="text-muted text-left" >{% trans "More login options" %}</b>
@ -215,6 +215,11 @@
<i class="fa"><img src="{{ LOGIN_DINGTALK_LOGO_URL }}" height="13" width="13"></i> {% trans 'DingTalk' %}
</a>
{% endif %}
{% if AUTH_FEISHU %}
<a href="{% url 'authentication:feishu-qr-login' %}" class="more-login-item">
<i class="fa"><img src="{{ LOGIN_FEISHU_LOGO_URL }}" height="13" width="13"></i> {% trans 'FeiShu' %}
</a>
{% endif %}
</div>
{% else %}

View File

@ -20,6 +20,10 @@ urlpatterns = [
path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'),
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'),
path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'),
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'),
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'),
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),

View File

@ -37,6 +37,14 @@ urlpatterns = [
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'),
path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'),
path('feishu/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='feishu-bind-success-flash-msg'),
path('feishu/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='feishu-bind-failed-flash-msg'),
path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'),
path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),
path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'),
path('feishu/qr/bind/callback/', views.FeiShuQRBindCallbackView.as_view(), name='feishu-qr-bind-callback'),
path('feishu/qr/login/callback/', views.FeiShuQRLoginCallbackView.as_view(), name='feishu-qr-login-callback'),
# Profile
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),

View File

@ -4,3 +4,4 @@ from .login import *
from .mfa import *
from .wecom import *
from .dingtalk import *
from .feishu import *

View File

@ -0,0 +1,253 @@
import urllib
from django.http.response import HttpResponseRedirect, HttpResponse
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.generic import TemplateView
from django.views import View
from django.conf import settings
from django.http.request import HttpRequest
from django.db.utils import IntegrityError
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.exceptions import APIException
from users.utils import is_auth_password_time_valid
from users.views import UserVerifyPasswordView
from users.models import User
from common.utils import get_logger
from common.utils.random import random_string
from common.utils.django import reverse, get_object_or_none
from common.mixins.views import PermissionsMixin
from common.message.backends.feishu import FeiShu, URL
from authentication import errors
from authentication.mixins import AuthMixin
logger = get_logger(__file__)
FEISHU_STATE_SESSION_KEY = '_feishu_state'
class FeiShuQRMixin(PermissionsMixin, View):
def dispatch(self, request, *args, **kwargs):
try:
return super().dispatch(request, *args, **kwargs)
except APIException as e:
msg = str(e.detail)
return self.get_failed_reponse(
'/',
_('FeiShu Error'),
msg
)
def verify_state(self):
state = self.request.GET.get('state')
session_state = self.request.session.get(FEISHU_STATE_SESSION_KEY)
if state != session_state:
return False
return True
def get_verify_state_failed_response(self, redirect_uri):
msg = _("You've been hacked")
return self.get_failed_reponse(redirect_uri, msg, msg)
def get_qr_url(self, redirect_uri):
state = random_string(16)
self.request.session[FEISHU_STATE_SESSION_KEY] = state
params = {
'app_id': settings.FEISHU_APP_ID,
'state': state,
'redirect_uri': redirect_uri,
}
url = URL.AUTHEN + '?' + urllib.parse.urlencode(params)
return url
def get_success_reponse(self, redirect_url, title, msg):
ok_flash_msg_url = reverse('authentication:feishu-bind-success-flash-msg')
ok_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
'title': title,
'msg': msg
})
return HttpResponseRedirect(ok_flash_msg_url)
def get_failed_reponse(self, redirect_url, title, msg):
failed_flash_msg_url = reverse('authentication:feishu-bind-failed-flash-msg')
failed_flash_msg_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url,
'title': title,
'msg': msg
})
return HttpResponseRedirect(failed_flash_msg_url)
def get_already_bound_response(self, redirect_url):
msg = _('FeiShu is already bound')
response = self.get_failed_reponse(redirect_url, msg, msg)
return response
class FeiShuQRBindView(FeiShuQRMixin, View):
permission_classes = (IsAuthenticated,)
def get(self, request: HttpRequest):
user = request.user
redirect_url = request.GET.get('redirect_url')
if not is_auth_password_time_valid(request.session):
msg = _('Please verify your password first')
response = self.get_failed_reponse(redirect_url, msg, msg)
return response
redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
permission_classes = (IsAuthenticated,)
def get(self, request: HttpRequest):
code = request.GET.get('code')
redirect_url = request.GET.get('redirect_url')
if not self.verify_state():
return self.get_verify_state_failed_response(redirect_url)
user = request.user
if user.feishu_id:
response = self.get_already_bound_response(redirect_url)
return response
feishu = FeiShu(
app_id=settings.FEISHU_APP_ID,
app_secret=settings.FEISHU_APP_SECRET
)
user_id = feishu.get_user_id_by_code(code)
if not user_id:
msg = _('FeiShu query user failed')
response = self.get_failed_reponse(redirect_url, msg, msg)
return response
try:
user.feishu_id = user_id
user.save()
except IntegrityError as e:
if e.args[0] == 1062:
msg = _('The FeiShu is already bound to another user')
response = self.get_failed_reponse(redirect_url, msg, msg)
return response
raise e
msg = _('Binding FeiShu successfully')
response = self.get_success_reponse(redirect_url, msg, msg)
return response
class FeiShuEnableStartView(UserVerifyPasswordView):
def get_success_url(self):
referer = self.request.META.get('HTTP_REFERER')
redirect_url = self.request.GET.get("redirect_url")
success_url = reverse('authentication:feishu-qr-bind')
success_url += '?' + urllib.parse.urlencode({
'redirect_url': redirect_url or referer
})
return success_url
class FeiShuQRLoginView(FeiShuQRMixin, View):
permission_classes = (AllowAny,)
def get(self, request: HttpRequest):
redirect_url = request.GET.get('redirect_url')
redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True)
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
url = self.get_qr_url(redirect_uri)
return HttpResponseRedirect(url)
class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
permission_classes = (AllowAny,)
def get(self, request: HttpRequest):
code = request.GET.get('code')
redirect_url = request.GET.get('redirect_url')
login_url = reverse('authentication:login')
if not self.verify_state():
return self.get_verify_state_failed_response(redirect_url)
feishu = FeiShu(
app_id=settings.FEISHU_APP_ID,
app_secret=settings.FEISHU_APP_SECRET
)
user_id = feishu.get_user_id_by_code(code)
if not user_id:
# 正常流程不会出这个错误hack 行为
msg = _('Failed to get user from FeiShu')
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
return response
user = get_object_or_none(User, feishu_id=user_id)
if user is None:
title = _('FeiShu is not bound')
msg = _('Please login with a password and then bind the WeCom')
response = self.get_failed_reponse(login_url, title=title, msg=msg)
return response
try:
self.check_oauth2_auth(user, settings.AUTH_BACKEND_FEISHU)
except errors.AuthFailedError as e:
self.set_login_failed_mark()
msg = e.msg
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
return response
return self.redirect_to_guard_view()
@method_decorator(never_cache, name='dispatch')
class FlashFeiShuBindSucceedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding FeiShu successfully'),
'messages': msg or _('Binding FeiShu successfully'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)
@method_decorator(never_cache, name='dispatch')
class FlashFeiShuBindFailedMsgView(TemplateView):
template_name = 'flash_message_standalone.html'
def get(self, request, *args, **kwargs):
title = request.GET.get('title')
msg = request.GET.get('msg')
context = {
'title': title or _('Binding FeiShu failed'),
'messages': msg or _('Binding FeiShu failed'),
'interval': 5,
'redirect_url': request.GET.get('redirect_url'),
'auto_redirect': True,
}
return self.render_to_response(context)

View File

@ -46,24 +46,44 @@ class UserLoginView(mixins.AuthMixin, FormView):
return None
next_url = request.GET.get('next') or '/'
auth_type = ''
auth_url = ''
if settings.AUTH_OPENID:
auth_type = 'OIDC'
auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}'
elif settings.AUTH_CAS:
openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}'
else:
openid_auth_url = None
if settings.AUTH_CAS:
auth_type = 'CAS'
auth_url = reverse(settings.CAS_LOGIN_URL_NAME) + f'?next={next_url}'
if not auth_url:
cas_auth_url = reverse(settings.CAS_LOGIN_URL_NAME) + f'?next={next_url}'
else:
cas_auth_url = None
if not any([openid_auth_url, cas_auth_url]):
return None
message_data = {
'title': _('Redirecting'),
'message': _("Redirecting to {} authentication").format(auth_type),
'redirect_url': auth_url,
'has_cancel': True,
'cancel_url': reverse('authentication:login') + '?admin=1'
}
redirect_url = FlashMessageUtil.gen_message_url(message_data)
if settings.LOGIN_REDIRECT_TO_BACKEND == 'OPENID' and openid_auth_url:
auth_url = openid_auth_url
elif settings.LOGIN_REDIRECT_TO_BACKEND == 'CAS' and cas_auth_url:
auth_url = cas_auth_url
else:
auth_url = openid_auth_url or cas_auth_url
if settings.LOGIN_REDIRECT_TO_BACKEND:
redirect_url = auth_url
else:
message_data = {
'title': _('Redirecting'),
'message': _("Redirecting to {} authentication").format(auth_type),
'redirect_url': auth_url,
'interval': 3,
'has_cancel': True,
'cancel_url': reverse('authentication:login') + '?admin=1'
}
redirect_url = FlashMessageUtil.gen_message_url(message_data)
query_string = request.GET.urlencode()
redirect_url = "{}&{}".format(redirect_url, query_string)
return redirect_url
@ -134,6 +154,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
'AUTH_CAS': settings.AUTH_CAS,
'AUTH_WECOM': settings.AUTH_WECOM,
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
'AUTH_FEISHU': settings.AUTH_FEISHU,
'rsa_public_key': rsa_public_key,
'forgot_password_url': forgot_password_url
}

View File

@ -10,3 +10,15 @@ def on_transaction_commit(func):
def inner(*args, **kwargs):
transaction.on_commit(lambda: func(*args, **kwargs))
return inner
class Singleton(object):
""" 单例类 """
def __init__(self, cls):
self._cls = cls
self._instance = {}
def __call__(self):
if self._cls not in self._instance:
self._instance[self._cls] = self._cls()
return self._instance[self._cls]

View File

@ -1,4 +1,4 @@
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet, ViewSet
from rest_framework_bulk import BulkModelViewSet
from ..mixins.api import (
@ -15,19 +15,23 @@ class CommonMixin(SerializerMixin,
pass
class JmsGenericViewSet(CommonMixin,
GenericViewSet):
class JMSGenericViewSet(CommonMixin, GenericViewSet):
pass
class JMSModelViewSet(CommonMixin,
ModelViewSet):
class JMSViewSet(CommonMixin, ViewSet):
pass
class JMSBulkModelViewSet(CommonMixin,
AllowBulkDestroyMixin,
BulkModelViewSet):
class JMSModelViewSet(CommonMixin, ModelViewSet):
pass
class JMSReadOnlyModelViewSet(CommonMixin, ReadOnlyModelViewSet):
pass
class JMSBulkModelViewSet(CommonMixin, AllowBulkDestroyMixin, BulkModelViewSet):
pass

View File

View File

@ -0,0 +1,6 @@
from .services.command import BaseActionCommand, Action
class Command(BaseActionCommand):
help = 'Restart services'
action = Action.restart.value

View File

@ -0,0 +1,139 @@
from django.core.management.base import BaseCommand, CommandError
from django.db.models import TextChoices
from .utils import ServicesUtil
from .hands import *
class Services(TextChoices):
gunicorn = 'gunicorn', 'gunicorn'
daphne = 'daphne', 'daphne'
celery_ansible = 'celery_ansible', 'celery_ansible'
celery_default = 'celery_default', 'celery_default'
beat = 'beat', 'beat'
flower = 'flower', 'flower'
ws = 'ws', 'ws'
web = 'web', 'web'
celery = 'celery', 'celery'
task = 'task', 'task'
all = 'all', 'all'
@classmethod
def get_service_object_class(cls, name):
from . import services
services_map = {
cls.gunicorn.value: services.GunicornService,
cls.daphne: services.DaphneService,
cls.flower: services.FlowerService,
cls.celery_default: services.CeleryDefaultService,
cls.celery_ansible: services.CeleryAnsibleService,
cls.beat: services.BeatService
}
return services_map.get(name)
@classmethod
def ws_services(cls):
return [cls.daphne]
@classmethod
def web_services(cls):
return [cls.gunicorn, cls.daphne]
@classmethod
def celery_services(cls):
return [cls.celery_ansible, cls.celery_default]
@classmethod
def task_services(cls):
return cls.celery_services() + [cls.beat, cls.flower]
@classmethod
def all_services(cls):
return cls.web_services() + cls.task_services()
@classmethod
def export_services_values(cls):
return [cls.all.value, cls.web.value, cls.task.value]
@classmethod
def get_service_objects(cls, service_names, **kwargs):
services = set()
for name in service_names:
method_name = f'{name}_services'
if hasattr(cls, method_name):
_services = getattr(cls, method_name)()
elif hasattr(cls, name):
_services = [getattr(cls, name)]
else:
continue
services.update(set(_services))
service_objects = []
for s in services:
service_class = cls.get_service_object_class(s.value)
if not service_class:
continue
kwargs.update({
'name': s.value
})
service_object = service_class(**kwargs)
service_objects.append(service_object)
return service_objects
class Action(TextChoices):
start = 'start', 'start'
status = 'status', 'status'
stop = 'stop', 'stop'
restart = 'restart', 'restart'
class BaseActionCommand(BaseCommand):
help = 'Service Base Command'
action = None
util = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def add_arguments(self, parser):
parser.add_argument(
'services', nargs='+', choices=Services.export_services_values(), help='Service',
)
parser.add_argument('-d', '--daemon', nargs="?", const=True)
parser.add_argument('-w', '--worker', type=int, nargs="?", default=4)
parser.add_argument('-f', '--force', nargs="?", const=True)
def initial_util(self, *args, **options):
service_names = options.get('services')
service_kwargs = {
'worker_gunicorn': options.get('worker')
}
services = Services.get_service_objects(service_names=service_names, **service_kwargs)
kwargs = {
'services': services,
'run_daemon': options.get('daemon', False),
'stop_daemon': self.action == Action.stop.value and Services.all.value in service_names,
'force_stop': options.get('force') or False,
}
self.util = ServicesUtil(**kwargs)
def handle(self, *args, **options):
self.initial_util(*args, **options)
assert self.action in Action.values, f'The action {self.action} is not in the optional list'
_handle = getattr(self, f'_handle_{self.action}', lambda: None)
_handle()
def _handle_start(self):
self.util.start_and_watch()
os._exit(0)
def _handle_stop(self):
self.util.stop()
def _handle_restart(self):
self.util.restart()
def _handle_status(self):
self.util.show_status()

View File

@ -0,0 +1,26 @@
import os
import sys
import logging
from django.conf import settings
from apps.jumpserver.const import CONFIG
try:
from apps.jumpserver import const
__version__ = const.VERSION
except ImportError as e:
print("Not found __version__: {}".format(e))
print("Python is: ")
logging.info(sys.executable)
__version__ = 'Unknown'
sys.exit(1)
HTTP_HOST = CONFIG.HTTP_BIND_HOST or '127.0.0.1'
HTTP_PORT = CONFIG.HTTP_LISTEN_PORT or 8080
WS_PORT = CONFIG.WS_LISTEN_PORT or 8082
DEBUG = CONFIG.DEBUG or False
BASE_DIR = os.path.dirname(settings.BASE_DIR)
LOG_DIR = os.path.join(BASE_DIR, 'logs')
APPS_DIR = os.path.join(BASE_DIR, 'apps')
TMP_DIR = os.path.join(BASE_DIR, 'tmp')

View File

@ -0,0 +1,6 @@
from .beat import *
from .celery_ansible import *
from .celery_default import *
from .daphne import *
from .flower import *
from .gunicorn import *

View File

@ -0,0 +1,204 @@
import abc
import time
import shutil
import psutil
import datetime
import threading
import subprocess
from ..hands import *
class BaseService(object):
def __init__(self, **kwargs):
self.name = kwargs['name']
self._process = None
self.STOP_TIMEOUT = 10
self.max_retry = 0
self.retry = 3
self.LOG_KEEP_DAYS = 7
self.EXIT_EVENT = threading.Event()
@property
@abc.abstractmethod
def cmd(self):
return []
@property
@abc.abstractmethod
def cwd(self):
return ''
@property
def is_running(self):
if self.pid == 0:
return False
try:
os.kill(self.pid, 0)
except (OSError, ProcessLookupError):
return False
else:
return True
def show_status(self):
if self.is_running:
msg = f'{self.name} is running: {self.pid}.'
else:
msg = f'{self.name} is stopped.'
print(msg)
# -- log --
@property
def log_filename(self):
return f'{self.name}.log'
@property
def log_filepath(self):
return os.path.join(LOG_DIR, self.log_filename)
@property
def log_file(self):
return open(self.log_filepath, 'a')
@property
def log_dir(self):
return os.path.dirname(self.log_filepath)
# -- end log --
# -- pid --
@property
def pid_filepath(self):
return os.path.join(TMP_DIR, f'{self.name}.pid')
@property
def pid(self):
if not os.path.isfile(self.pid_filepath):
return 0
with open(self.pid_filepath) as f:
try:
pid = int(f.read().strip())
except ValueError:
pid = 0
return pid
def write_pid(self):
with open(self.pid_filepath, 'w') as f:
f.write(str(self.process.pid))
def remove_pid(self):
if os.path.isfile(self.pid_filepath):
os.unlink(self.pid_filepath)
# -- end pid --
# -- process --
@property
def process(self):
if not self._process:
try:
self._process = psutil.Process(self.pid)
except:
pass
return self._process
# -- end process --
# -- action --
def open_subprocess(self):
kwargs = {'cwd': self.cwd, 'stderr': self.log_file, 'stdout': self.log_file}
self._process = subprocess.Popen(self.cmd, **kwargs)
def start(self):
if self.is_running:
self.show_status()
return
self.remove_pid()
self.open_subprocess()
self.write_pid()
self.start_other()
def start_other(self):
pass
def stop(self, force=False):
if not self.is_running:
self.show_status()
# self.remove_pid()
return
print(f'Stop service: {self.name}', end='')
sig = 9 if force else 15
os.kill(self.pid, sig)
if self.process is None:
print("\033[31m No process found\033[0m")
return
try:
self.process.wait(1)
except:
pass
for i in range(self.STOP_TIMEOUT):
if i == self.STOP_TIMEOUT - 1:
print("\033[31m Error\033[0m")
if not self.is_running:
print("\033[32m Ok\033[0m")
self.remove_pid()
break
else:
time.sleep(1)
continue
def watch(self):
self._check()
if not self.is_running:
self._restart()
self._rotate_log()
def _check(self):
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"{now} Check service status: {self.name} -> ", end='')
if self.process:
try:
self.process.wait(1) # 不wait子进程可能无法回收
except subprocess.TimeoutExpired:
pass
if self.is_running:
print(f'running at {self.pid}')
else:
print(f'stopped at {self.pid}')
def _restart(self):
if self.retry > self.max_retry:
logging.info("Service start failed, exit: ", self.name)
self.EXIT_EVENT.set()
return
self.retry += 1
logging.info(f'> Find {self.name} stopped, retry {self.retry}, {self.pid}')
self.start()
def _rotate_log(self):
now = datetime.datetime.now()
_time = now.strftime('%H:%M')
if _time != '23:59':
return
backup_date = now.strftime('%Y-%m-%d')
backup_log_dir = os.path.join(self.log_dir, backup_date)
if not os.path.exists(backup_log_dir):
os.mkdir(backup_log_dir)
backup_log_path = os.path.join(backup_log_dir, self.log_filename)
if os.path.isfile(self.log_filepath) and not os.path.isfile(backup_log_path):
logging.info(f'Rotate log file: {self.log_filepath} => {backup_log_path}')
shutil.copy(self.log_filepath, backup_log_path)
with open(self.log_filepath, 'w') as f:
pass
to_delete_date = now - datetime.timedelta(days=self.LOG_KEEP_DAYS)
to_delete_dir = os.path.join(LOG_DIR, to_delete_date.strftime('%Y-%m-%d'))
if os.path.exists(to_delete_dir):
logging.info(f'Remove old log: {to_delete_dir}')
shutil.rmtree(to_delete_dir, ignore_errors=True)
# -- end action --

View File

@ -0,0 +1,25 @@
from ..hands import *
from .base import BaseService
from django.core.cache import cache
__all__ = ['BeatService']
class BeatService(BaseService):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.lock = cache.lock('beat-distribute-start-lock', expire=60)
@property
def cmd(self):
print("\n- Start Beat as Periodic Task Scheduler")
cmd = [
sys.executable, 'start_celery_beat.py',
]
return cmd
@property
def cwd(self):
return os.path.join(BASE_DIR, 'utils')

View File

@ -0,0 +1,11 @@
from .celery_base import CeleryBaseService
__all__ = ['CeleryAnsibleService']
class CeleryAnsibleService(CeleryBaseService):
def __init__(self, **kwargs):
kwargs['queue'] = 'ansible'
super().__init__(**kwargs)

View File

@ -0,0 +1,38 @@
from ..hands import *
from .base import BaseService
class CeleryBaseService(BaseService):
def __init__(self, queue, num=10, **kwargs):
super().__init__(**kwargs)
self.queue = queue
self.num = num
@property
def cmd(self):
print('\n- Start Celery as Distributed Task Queue: {}'.format(self.queue.capitalize()))
os.environ.setdefault('PYTHONOPTIMIZE', '1')
os.environ.setdefault('ANSIBLE_FORCE_COLOR', 'True')
if os.getuid() == 0:
os.environ.setdefault('C_FORCE_ROOT', '1')
server_hostname = os.environ.get("SERVER_HOSTNAME")
if not server_hostname:
server_hostname = '%h'
cmd = [
'celery', 'worker',
'-P', 'threads',
'-A', 'ops',
'-l', 'INFO',
'-c', str(self.num),
'-Q', self.queue,
'-n', f'{self.queue}@{server_hostname}'
]
return cmd
@property
def cwd(self):
return APPS_DIR

View File

@ -0,0 +1,16 @@
from .celery_base import CeleryBaseService
__all__ = ['CeleryDefaultService']
class CeleryDefaultService(CeleryBaseService):
def __init__(self, **kwargs):
kwargs['queue'] = 'celery'
super().__init__(**kwargs)
def start_other(self):
from terminal.startup import CeleryTerminal
celery_terminal = CeleryTerminal()
celery_terminal.start_heartbeat_thread()

View File

@ -0,0 +1,25 @@
from ..hands import *
from .base import BaseService
__all__ = ['DaphneService']
class DaphneService(BaseService):
def __init__(self, **kwargs):
super().__init__(**kwargs)
@property
def cmd(self):
print("\n- Start Daphne ASGI WS Server")
cmd = [
'daphne', 'jumpserver.asgi:application',
'-b', HTTP_HOST,
'-p', str(WS_PORT),
]
return cmd
@property
def cwd(self):
return APPS_DIR

View File

@ -0,0 +1,31 @@
from ..hands import *
from .base import BaseService
__all__ = ['FlowerService']
class FlowerService(BaseService):
def __init__(self, **kwargs):
super().__init__(**kwargs)
@property
def cmd(self):
print("\n- Start Flower as Task Monitor")
if os.getuid() == 0:
os.environ.setdefault('C_FORCE_ROOT', '1')
cmd = [
'celery', 'flower',
'-A', 'ops',
'-l', 'INFO',
'--url_prefix=/core/flower',
'--auto_refresh=False',
'--max_tasks=1000',
'--tasks_columns=uuid,name,args,state,received,started,runtime,worker'
]
return cmd
@property
def cwd(self):
return APPS_DIR

View File

@ -0,0 +1,40 @@
from ..hands import *
from .base import BaseService
__all__ = ['GunicornService']
class GunicornService(BaseService):
def __init__(self, **kwargs):
self.worker = kwargs['worker_gunicorn']
super().__init__(**kwargs)
@property
def cmd(self):
print("\n- Start Gunicorn WSGI HTTP Server")
log_format = '%(h)s %(t)s %(L)ss "%(r)s" %(s)s %(b)s '
bind = f'{HTTP_HOST}:{HTTP_PORT}'
cmd = [
'gunicorn', 'jumpserver.wsgi',
'-b', bind,
'-k', 'gthread',
'--threads', '10',
'-w', str(self.worker),
'--max-requests', '4096',
'--access-logformat', log_format,
'--access-logfile', '-'
]
if DEBUG:
cmd.append('--reload')
return cmd
@property
def cwd(self):
return APPS_DIR
def start_other(self):
from terminal.startup import CoreTerminal
core_terminal = CoreTerminal()
core_terminal.start_heartbeat_thread()

View File

@ -0,0 +1,140 @@
import threading
import signal
import time
import daemon
from daemon import pidfile
from .hands import *
from .hands import __version__
from .services.base import BaseService
class ServicesUtil(object):
def __init__(self, services, run_daemon=False, force_stop=False, stop_daemon=False):
self._services = services
self.run_daemon = run_daemon
self.force_stop = force_stop
self.stop_daemon = stop_daemon
self.EXIT_EVENT = threading.Event()
self.check_interval = 30
self.files_preserve_map = {}
def restart(self):
self.stop()
time.sleep(5)
self.start_and_watch()
def start_and_watch(self):
logging.info(time.ctime())
logging.info(f'JumpServer version {__version__}, more see https://www.jumpserver.org')
self.start()
if self.run_daemon:
self.show_status()
with self.daemon_context:
self.watch()
else:
self.watch()
def start(self):
for service in self._services:
service: BaseService
service.start()
self.files_preserve_map[service.name] = service.log_file
time.sleep(1)
def stop(self):
for service in self._services:
service: BaseService
service.stop(force=self.force_stop)
if self.stop_daemon:
self._stop_daemon()
# -- watch --
def watch(self):
while not self.EXIT_EVENT.is_set():
try:
_exit = self._watch()
if _exit:
break
time.sleep(self.check_interval)
except KeyboardInterrupt:
print('Start stop services')
break
self.clean_up()
def _watch(self):
for service in self._services:
service: BaseService
service.watch()
if service.EXIT_EVENT.is_set():
self.EXIT_EVENT.set()
return True
return False
# -- end watch --
def clean_up(self):
if not self.EXIT_EVENT.is_set():
self.EXIT_EVENT.set()
self.stop()
def show_status(self):
for service in self._services:
service: BaseService
service.show_status()
# -- daemon --
def _stop_daemon(self):
if self.daemon_pid and self.daemon_is_running:
os.kill(self.daemon_pid, 15)
self.remove_daemon_pid()
def remove_daemon_pid(self):
if os.path.isfile(self.daemon_pid_filepath):
os.unlink(self.daemon_pid_filepath)
@property
def daemon_pid(self):
if not os.path.isfile(self.daemon_pid_filepath):
return 0
with open(self.daemon_pid_filepath) as f:
try:
pid = int(f.read().strip())
except ValueError:
pid = 0
return pid
@property
def daemon_is_running(self):
try:
os.kill(self.daemon_pid, 0)
except (OSError, ProcessLookupError):
return False
else:
return True
@property
def daemon_pid_filepath(self):
return os.path.join(TMP_DIR, 'jms.pid')
@property
def daemon_log_filepath(self):
return os.path.join(LOG_DIR, 'jms.log')
@property
def daemon_context(self):
daemon_log_file = open(self.daemon_log_filepath, 'a')
context = daemon.DaemonContext(
pidfile=pidfile.TimeoutPIDLockFile(self.daemon_pid_filepath),
signal_map={
signal.SIGTERM: lambda x, y: self.clean_up(),
signal.SIGHUP: 'terminate',
},
stdout=daemon_log_file,
stderr=daemon_log_file,
files_preserve=list(self.files_preserve_map.values()),
detach_process=True,
)
return context
# -- end daemon --

View File

@ -0,0 +1,6 @@
from .services.command import BaseActionCommand, Action
class Command(BaseActionCommand):
help = 'Start services'
action = Action.start.value

View File

@ -0,0 +1,6 @@
from .services.command import BaseActionCommand, Action
class Command(BaseActionCommand):
help = 'Show services status'
action = Action.status.value

View File

@ -0,0 +1,6 @@
from .services.command import BaseActionCommand, Action
class Command(BaseActionCommand):
help = 'Stop services'
action = Action.stop.value

View File

@ -2,8 +2,7 @@ import time
import hmac
import base64
from common.message.backends.utils import request
from common.message.backends.utils import digest
from common.message.backends.utils import digest, as_request
from common.message.backends.mixin import BaseRequest
@ -34,7 +33,7 @@ class URL:
class DingTalkRequests(BaseRequest):
invalid_token_errcode = ErrorCode.INVALID_TOKEN
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
def __init__(self, appid, appsecret, agentid, timeout=None):
self._appid = appid
@ -55,21 +54,33 @@ class DingTalkRequests(BaseRequest):
expires_in = data['expires_in']
return access_token, expires_in
@request
def add_token(self, kwargs: dict):
params = kwargs.get('params')
if params is None:
params = {}
kwargs['params'] = params
params['access_token'] = self.access_token
def get(self, url, params=None,
with_token=False, with_sign=False,
check_errcode_is_0=True,
**kwargs):
pass
get = as_request(get)
@request
def post(self, url, json=None, params=None,
with_token=False, with_sign=False,
check_errcode_is_0=True,
**kwargs):
pass
post = as_request(post)
def _add_sign(self, kwargs: dict):
params = kwargs.get('params')
if params is None:
params = {}
kwargs['params'] = params
def _add_sign(self, params: dict):
timestamp = str(int(time.time() * 1000))
signature = sign(self._appsecret, timestamp)
accessKey = self._appid
@ -78,23 +89,17 @@ class DingTalkRequests(BaseRequest):
params['signature'] = signature
params['accessKey'] = accessKey
def request(self, method, url, params=None,
def request(self, method, url,
with_token=False, with_sign=False,
check_errcode_is_0=True,
**kwargs):
if not isinstance(params, dict):
params = {}
if with_token:
params['access_token'] = self.access_token
if with_sign:
self._add_sign(params)
data = self.raw_request(method, url, params=params, **kwargs)
if check_errcode_is_0:
self.check_errcode_is_0(data)
self._add_sign(kwargs)
data = super().request(
method, url, with_token=with_token,
check_errcode_is_0=check_errcode_is_0, **kwargs)
return data

View File

@ -0,0 +1,114 @@
import json
from django.utils.translation import ugettext_lazy as _
from rest_framework.exceptions import APIException
from common.utils.common import get_logger
from common.message.backends.utils import digest
from common.message.backends.mixin import RequestMixin, BaseRequest
logger = get_logger(__name__)
class URL:
AUTHEN = 'https://open.feishu.cn/open-apis/authen/v1/index'
GET_TOKEN = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/'
# https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN
GET_USER_INFO_BY_CODE = 'https://open.feishu.cn/open-apis/authen/v1/access_token'
SEND_MESSAGE = 'https://open.feishu.cn/open-apis/im/v1/messages'
class ErrorCode:
INVALID_APP_ACCESS_TOKEN = 99991664
INVALID_USER_ACCESS_TOKEN = 99991668
INVALID_TENANT_ACCESS_TOKEN = 99991663
class FeishuRequests(BaseRequest):
"""
处理系统级错误抛出 API 异常直接生成 HTTP 响应业务代码无需关心这些错误
- 确保 status_code == 200
- 确保 access_token 无效时重试
"""
invalid_token_errcodes = (
ErrorCode.INVALID_USER_ACCESS_TOKEN, ErrorCode.INVALID_TENANT_ACCESS_TOKEN,
ErrorCode.INVALID_APP_ACCESS_TOKEN
)
code_key = 'code'
msg_key = 'msg'
def __init__(self, app_id, app_secret, timeout=None):
self._app_id = app_id
self._app_secret = app_secret
super().__init__(timeout=timeout)
def get_access_token_cache_key(self):
return digest(self._app_id, self._app_secret)
def request_access_token(self):
data = {'app_id': self._app_id, 'app_secret': self._app_secret}
response = self.raw_request('post', url=URL.GET_TOKEN, data=data)
self.check_errcode_is_0(response)
access_token = response['tenant_access_token']
expires_in = response['expire']
return access_token, expires_in
def add_token(self, kwargs: dict):
headers = kwargs.setdefault('headers', {})
headers['Authorization'] = f'Bearer {self.access_token}'
class FeiShu(RequestMixin):
"""
非业务数据导致的错误直接抛异常说明是系统配置错误业务代码不用理会
"""
def __init__(self, app_id, app_secret, timeout=None):
self._app_id = app_id
self._app_secret = app_secret
self._requests = FeishuRequests(
app_id=app_id,
app_secret=app_secret,
timeout=timeout
)
def get_user_id_by_code(self, code):
# https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN
body = {
'grant_type': 'authorization_code',
'code': code
}
data = self._requests.post(URL.GET_USER_INFO_BY_CODE, json=body, check_errcode_is_0=False)
self._requests.check_errcode_is_0(data)
return data['data']['user_id']
def send_text(self, user_ids, msg):
params = {
'receive_id_type': 'user_id'
}
body = {
'msg_type': 'text',
'content': json.dumps({'text': msg})
}
invalid_users = []
for user_id in user_ids:
body['receive_id'] = user_id
try:
self._requests.post(URL.SEND_MESSAGE, params=params, json=body)
except APIException as e:
# 只处理可预知的错误
logger.exception(e)
invalid_users.append(user_id)
return invalid_users

View File

@ -6,7 +6,7 @@ from django.core.cache import cache
from .utils import DictWrapper
from common.utils.common import get_logger
from common.utils import lazyproperty
from common.message.backends.utils import set_default
from common.message.backends.utils import set_default, as_request
from . import exceptions as exce
@ -14,17 +14,37 @@ logger = get_logger(__name__)
class RequestMixin:
def check_errcode_is_0(self, data: DictWrapper):
errcode = data['errcode']
code_key: str
msg_key: str
class BaseRequest(RequestMixin):
"""
定义了 `access_token` 的过期刷新框架
"""
invalid_token_errcodes = ()
code_key = 'errcode'
msg_key = 'err_msg'
def __init__(self, timeout=None):
self._request_kwargs = {
'timeout': timeout
}
self.init_access_token()
@classmethod
def check_errcode_is_0(cls, data: DictWrapper):
errcode = data[cls.code_key]
if errcode != 0:
# 如果代码写的对,配置没问题,这里不该出错,系统性错误,直接抛异常
errmsg = data['errmsg']
errmsg = data[cls.msg_key]
logger.error(f'Response 200 but errcode is not 0: '
f'errcode={errcode} '
f'errmsg={errmsg} ')
raise exce.ErrCodeNot0(detail=data.raw_data)
def check_http_is_200(self, response):
@staticmethod
def check_http_is_200(response):
if response.status_code != 200:
# 正常情况下不会返回非 200 响应码
logger.error(f'Response error: '
@ -33,25 +53,28 @@ class RequestMixin:
f'\ncontent={response.content}')
raise exce.HTTPNot200(detail=response.json())
class BaseRequest(RequestMixin):
invalid_token_errcode = -1
def __init__(self, timeout=None):
self._request_kwargs = {
'timeout': timeout
}
self.init_access_token()
def request_access_token(self):
"""
获取新的 `access_token` 的方法子类需要实现
"""
raise NotImplementedError
def get_access_token_cache_key(self):
"""
获取 `access_token` 的缓存 key 子类需要实现
"""
raise NotImplementedError
def add_token(self, kwargs: dict):
"""
添加 token 子类需要实现
"""
raise NotImplementedError
def is_token_invalid(self, data):
errcode = data['errcode']
if errcode == self.invalid_token_errcode:
code = data[self.code_key]
if code in self.invalid_token_errcodes:
logger.error(f'OAuth token invalid: {data}')
return True
return False
@ -69,26 +92,58 @@ class BaseRequest(RequestMixin):
def refresh_access_token(self):
access_token, expires_in = self.request_access_token()
self.access_token = access_token
cache.set(self.access_token_cache_key, access_token, expires_in)
cache.set(self.access_token_cache_key, access_token, expires_in - 10)
def raw_request(self, method, url, **kwargs):
set_default(kwargs, self._request_kwargs)
raw_data = ''
try:
response = getattr(requests, method)(url, **kwargs)
self.check_http_is_200(response)
raw_data = response.json()
data = DictWrapper(raw_data)
return data
except req_exce.ReadTimeout as e:
logger.exception(e)
raise exce.NetError
def token_request(self, method, url, **kwargs):
for i in range(3):
# 循环为了防止 access_token 失效
try:
response = getattr(requests, method)(url, **kwargs)
self.check_http_is_200(response)
raw_data = response.json()
data = DictWrapper(raw_data)
self.add_token(kwargs)
data = self.raw_request(method, url, **kwargs)
if self.is_token_invalid(data):
self.refresh_access_token()
continue
if self.is_token_invalid(data):
self.refresh_access_token()
continue
return data
except req_exce.ReadTimeout as e:
logger.exception(e)
raise exce.NetError
logger.error(f'Get access_token error, check config: url={url} data={raw_data}')
raise PermissionDenied(raw_data)
return data
logger.error(f'Get access_token error, check config: url={url} data={data.raw_data}')
raise PermissionDenied(data.raw_data)
def get(self, url, params=None, with_token=True,
check_errcode_is_0=True, **kwargs):
# self.request ...
pass
get = as_request(get)
def post(self, url, params=None, json=None,
with_token=True, check_errcode_is_0=True,
**kwargs):
# self.request ...
pass
post = as_request(post)
def request(self, method, url,
with_token=True,
check_errcode_is_0=True,
**kwargs):
if with_token:
data = self.token_request(method, url, **kwargs)
else:
data = self.raw_request(method, url, **kwargs)
if check_errcode_is_0:
self.check_errcode_is_0(data)
return data

View File

@ -54,7 +54,7 @@ class DictWrapper:
return str(self.raw_data)
def request(func):
def as_request(func):
def inner(*args, **kwargs):
signature = inspect.signature(func)
bound_args = signature.bind(*args, **kwargs)

View File

@ -2,13 +2,9 @@ from typing import Iterable, AnyStr
from django.utils.translation import ugettext_lazy as _
from rest_framework.exceptions import APIException
from requests.exceptions import ReadTimeout
import requests
from django.core.cache import cache
from common.utils.common import get_logger
from common.message.backends.utils import digest, DictWrapper, update_values, set_default
from common.message.backends.utils import request
from common.message.backends.mixin import RequestMixin, BaseRequest
logger = get_logger(__name__)
@ -48,7 +44,7 @@ class WeComRequests(BaseRequest):
- 确保 status_code == 200
- 确保 access_token 无效时重试
"""
invalid_token_errcode = ErrorCode.INVALID_TOKEN
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
def __init__(self, corpid, corpsecret, agentid, timeout=None):
self._corpid = corpid
@ -68,35 +64,13 @@ class WeComRequests(BaseRequest):
expires_in = data['expires_in']
return access_token, expires_in
@request
def get(self, url, params=None, with_token=True,
check_errcode_is_0=True, **kwargs):
# self.request ...
pass
@request
def post(self, url, params=None, json=None,
with_token=True, check_errcode_is_0=True,
**kwargs):
# self.request ...
pass
def request(self, method, url,
params=None,
with_token=True,
check_errcode_is_0=True,
**kwargs):
if not isinstance(params, dict):
def add_token(self, kwargs: dict):
params = kwargs.get('params')
if params is None:
params = {}
kwargs['params'] = params
if with_token:
params['access_token'] = self.access_token
data = self.raw_request(method, url, params=params, **kwargs)
if check_errcode_is_0:
self.check_errcode_is_0(data)
return data
params['access_token'] = self.access_token
class WeCom(RequestMixin):
@ -147,7 +121,7 @@ class WeCom(RequestMixin):
if errcode in (ErrorCode.RECIPIENTS_INVALID, ErrorCode.RECIPIENTS_EMPTY):
# 全部接收人无权限或不存在
return users
self.check_errcode_is_0(data)
self._requests.check_errcode_is_0(data)
invaliduser = data['invaliduser']
if not invaliduser:
@ -173,7 +147,7 @@ class WeCom(RequestMixin):
logger.warn(f'WeCom get_user_id_by_code invalid code: code={code}')
return None, None
self.check_errcode_is_0(data)
self._requests.check_errcode_is_0(data)
USER_ID = 'UserId'
OPEN_ID = 'OpenId'

View File

@ -24,6 +24,7 @@ from ..utils import lazyproperty
__all__ = [
'JSONResponseMixin', 'CommonApiMixin', 'AsyncApiMixin', 'RelationMixin',
'QuerySetMixin', 'ExtraFilterFieldsMixin', 'RenderToJsonMixin',
'SerializerMixin', 'AllowBulkDestroyMixin', 'PaginatedResponseMixin'
]

View File

@ -112,6 +112,9 @@ class UserCanUpdateSSHKey(permissions.BasePermission):
class NeedMFAVerify(permissions.BasePermission):
def has_permission(self, request, view):
if not settings.SECURITY_VIEW_AUTH_NEED_MFA:
return True
mfa_verify_time = request.session.get('MFA_VERIFY_TIME', 0)
if time.time() - mfa_verify_time < settings.SECURITY_MFA_VERIFY_TTL:
return True

View File

@ -1,15 +0,0 @@
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from orgs.utils import current_org
class RequestLogMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
print(f'Request {request.method} --> ', request.get_raw_uri())
response: HttpResponse = self.get_response(request)
print(f'Response {current_org.name} {request.method} {response.status_code} --> ', request.get_raw_uri())
return response

View File

@ -8,4 +8,5 @@ from .http import *
from .ipip import *
from .crypto import *
from .random import *
from .jumpserver import *
from .jumpserver import *
from .ip import *

View File

@ -7,8 +7,6 @@ import logging
import datetime
import uuid
from functools import wraps
import string
import random
import time
import ipaddress
import psutil
@ -242,11 +240,20 @@ class lazyproperty:
return value
def get_disk_usage():
partitions = psutil.disk_partitions()
mount_points = [p.mountpoint for p in partitions]
usages = {p: psutil.disk_usage(p) for p in mount_points}
return usages
def get_disk_usage(path):
return psutil.disk_usage(path=path).percent
def get_cpu_load():
cpu_load_1, cpu_load_5, cpu_load_15 = psutil.getloadavg()
cpu_count = psutil.cpu_count()
single_cpu_load_1 = cpu_load_1 / cpu_count
single_cpu_load_1 = '%.2f' % single_cpu_load_1
return float(single_cpu_load_1)
def get_memory_usage():
return psutil.virtual_memory().percent
class Time:
@ -273,3 +280,17 @@ def bulk_get(d, *keys, default=None):
for key in keys:
values.append(d.get(key, default))
return values
def unique(objects, key=None):
seen = OrderedDict()
if key is None:
key = lambda item: item
for obj in objects:
v = key(obj)
if v not in seen:
seen[v] = obj
return list(seen.values())

68
apps/common/utils/ip.py Normal file
View File

@ -0,0 +1,68 @@
from ipaddress import ip_network, ip_address
def is_ip_address(address):
""" 192.168.10.1 """
try:
ip_address(address)
except ValueError:
return False
else:
return True
def is_ip_network(ip):
""" 192.168.1.0/24 """
try:
ip_network(ip)
except ValueError:
return False
else:
return True
def is_ip_segment(ip):
""" 10.1.1.1-10.1.1.20 """
if '-' not in ip:
return False
ip_address1, ip_address2 = ip.split('-')
return is_ip_address(ip_address1) and is_ip_address(ip_address2)
def in_ip_segment(ip, ip_segment):
ip1, ip2 = ip_segment.split('-')
ip1 = int(ip_address(ip1))
ip2 = int(ip_address(ip2))
ip = int(ip_address(ip))
return min(ip1, ip2) <= ip <= max(ip1, ip2)
def contains_ip(ip, ip_group):
"""
ip_group:
[192.168.10.1, 192.168.1.0/24, 10.1.1.1-10.1.1.20, 2001:db8:2de::e13, 2001:db8:1a:1110::/64.]
"""
if '*' in ip_group:
return True
for _ip in ip_group:
if is_ip_address(_ip):
# 192.168.10.1
if ip == _ip:
return True
elif is_ip_network(_ip) and is_ip_address(ip):
# 192.168.1.0/24
if ip_address(ip) in ip_network(_ip):
return True
elif is_ip_segment(_ip) and is_ip_address(ip):
# 10.1.1.1-10.1.1.20
if in_ip_segment(ip, _ip):
return True
else:
# is domain name
if ip == _ip:
return True
return False

View File

@ -16,9 +16,7 @@ import json
import yaml
from importlib import import_module
from django.urls import reverse_lazy
from django.templatetags.static import static
from urllib.parse import urljoin, urlparse
from django.utils.translation import ugettext_lazy as _
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_DIR = os.path.dirname(BASE_DIR)
@ -230,6 +228,10 @@ class Config(dict):
'DINGTALK_APPKEY': '',
'DINGTALK_APPSECRET': '',
'AUTH_FEISHU': False,
'FEISHU_APP_ID': '',
'FEISHU_APP_SECRET': '',
'OTP_VALID_WINDOW': 2,
'OTP_ISSUER_NAME': 'JumpServer',
'EMAIL_SUFFIX': 'jumpserver.org',
@ -244,7 +246,7 @@ class Config(dict):
'TERMINAL_TELNET_REGEX': '',
'TERMINAL_COMMAND_STORAGE': {},
'SECURITY_MFA_AUTH': False,
'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启
'SECURITY_COMMAND_EXECUTION': True,
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True,
'SECURITY_VIEW_AUTH_NEED_MFA': True,
@ -253,6 +255,7 @@ class Config(dict):
'SECURITY_MAX_IDLE_TIME': 30,
'SECURITY_PASSWORD_EXPIRATION_TIME': 9999,
'SECURITY_PASSWORD_MIN_LENGTH': 6,
'SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH': 6,
'SECURITY_PASSWORD_UPPER_CASE': False,
'SECURITY_PASSWORD_LOWER_CASE': False,
'SECURITY_PASSWORD_NUMBER': False,
@ -264,6 +267,7 @@ class Config(dict):
'SECURITY_INSECURE_COMMAND_LEVEL': 5,
'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '',
'SECURITY_LUNA_REMEMBER_AUTH': True,
'SECURITY_WATERMARK_ENABLED': False,
'HTTP_BIND_HOST': '0.0.0.0',
'HTTP_LISTEN_PORT': 8080,
@ -301,11 +305,11 @@ class Config(dict):
'CONNECTION_TOKEN_ENABLED': False,
'ONLY_ALLOW_EXIST_USER_AUTH': False,
'ONLY_ALLOW_AUTH_FROM_SOURCE': False,
'DISK_CHECK_ENABLED': True,
'SESSION_SAVE_EVERY_REQUEST': True,
'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False,
'FORGOT_PASSWORD_URL': '',
'HEALTH_CHECK_TOKEN': '',
'LOGIN_REDIRECT_TO_BACKEND': None, # 'OPENID / CAS
'TERMINAL_RDP_ADDR': ''
}

View File

@ -16,6 +16,7 @@ def jumpserver_processor(request):
'LOGIN_CAS_LOGO_URL': static('img/login_cas_logo.png'),
'LOGIN_WECOM_LOGO_URL': static('img/login_wecom_logo.png'),
'LOGIN_DINGTALK_LOGO_URL': static('img/login_dingtalk_logo.png'),
'LOGIN_FEISHU_LOGO_URL': static('img/login_feishu_logo.png'),
'JMS_TITLE': _('JumpServer Open Source Bastion Host'),
'VERSION': settings.VERSION,
'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2021',

View File

@ -117,6 +117,10 @@ DINGTALK_AGENTID = CONFIG.DINGTALK_AGENTID
DINGTALK_APPKEY = CONFIG.DINGTALK_APPKEY
DINGTALK_APPSECRET = CONFIG.DINGTALK_APPSECRET
# FeiShu auth
AUTH_FEISHU = CONFIG.AUTH_FEISHU
FEISHU_APP_ID = CONFIG.FEISHU_APP_ID
FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
# Other setting
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
@ -134,12 +138,13 @@ AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend'
AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication'
AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication'
AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication'
AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication'
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication'
AUTHENTICATION_BACKENDS = [
AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM,
AUTH_BACKEND_DINGTALK, AUTH_BACKEND_AUTH_TOKEN
AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_AUTH_TOKEN,
]
if AUTH_CAS:

View File

@ -38,6 +38,7 @@ SECURITY_LOGIN_LIMIT_TIME = CONFIG.SECURITY_LOGIN_LIMIT_TIME # Unit: minute
SECURITY_MAX_IDLE_TIME = CONFIG.SECURITY_MAX_IDLE_TIME # Unit: minute
SECURITY_PASSWORD_EXPIRATION_TIME = CONFIG.SECURITY_PASSWORD_EXPIRATION_TIME # Unit: day
SECURITY_PASSWORD_MIN_LENGTH = CONFIG.SECURITY_PASSWORD_MIN_LENGTH # Unit: bit
SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH = CONFIG.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH # Unit: bit
OLD_PASSWORD_HISTORY_LIMIT_COUNT = CONFIG.OLD_PASSWORD_HISTORY_LIMIT_COUNT
SECURITY_PASSWORD_UPPER_CASE = CONFIG.SECURITY_PASSWORD_UPPER_CASE
SECURITY_PASSWORD_LOWER_CASE = CONFIG.SECURITY_PASSWORD_LOWER_CASE
@ -118,7 +119,6 @@ TICKETS_ENABLED = CONFIG.TICKETS_ENABLED
REFERER_CHECK_ENABLED = CONFIG.REFERER_CHECK_ENABLED
CONNECTION_TOKEN_ENABLED = CONFIG.CONNECTION_TOKEN_ENABLED
DISK_CHECK_ENABLED = CONFIG.DISK_CHECK_ENABLED
FORGOT_PASSWORD_URL = CONFIG.FORGOT_PASSWORD_URL
@ -128,3 +128,6 @@ HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN
TERMINAL_RDP_ADDR = CONFIG.TERMINAL_RDP_ADDR
SECURITY_LUNA_REMEMBER_AUTH = CONFIG.SECURITY_LUNA_REMEMBER_AUTH
SECURITY_WATERMARK_ENABLED = CONFIG.SECURITY_WATERMARK_ENABLED
LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND

View File

@ -40,8 +40,8 @@ REST_FRAMEWORK = {
'DEFAULT_METADATA_CLASS': 'common.drf.metadata.SimpleMetadataWithFilters',
'ORDERING_PARAM': "order",
'SEARCH_PARAM': "search",
'DATETIME_FORMAT': '%Y-%m-%d %H:%M:%S %z',
'DATETIME_INPUT_FORMATS': ['iso-8601', '%Y-%m-%d %H:%M:%S %z'],
'DATETIME_FORMAT': '%Y/%m/%d %H:%M:%S %z',
'DATETIME_INPUT_FORMATS': ['%Y/%m/%d %H:%M:%S %z', 'iso-8601', '%Y-%m-%d %H:%M:%S %z'],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'EXCEPTION_HANDLER': 'common.drf.exc_handlers.common_exception_handler',
# 'PAGE_SIZE': 100,

View File

@ -20,7 +20,7 @@ def celery_flower_view(request, path):
try:
response = proxy_view(request, remote_url)
except Exception as e:
msg = _("<h1>Flow service unavailable, check it</h1>") + \
msg = _("<h1>Flower service unavailable, check it</h1>") + \
'<br><br> <div>{}</div>'.format(e)
response = HttpResponse(msg)
return response

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -73,7 +73,7 @@ msgid ""
"User list、User group、Asset list、Domain list、Admin user、System user、"
"Labels、Asset permission"
msgstr ""
"用户列表、用户组、资产列表、网域列表、管理用户、系统用户、标签管理、资产授权"
"用户列表、用户组、资产列表、网域列表、特权用户、系统用户、标签管理、资产授权"
"规则"
#: static/js/jumpserver.js:416

View File

@ -4,7 +4,7 @@ from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from common.drf.api import JmsGenericViewSet
from common.drf.api import JMSGenericViewSet
from notifications.notifications import system_msgs
from notifications.models import SystemMsgSubscription
from notifications.backends import BACKEND
@ -30,7 +30,7 @@ class BackendListView(APIView):
class SystemMsgSubscriptionViewSet(ListModelMixin,
UpdateModelMixin,
JmsGenericViewSet):
JMSGenericViewSet):
lookup_field = 'message_type'
queryset = SystemMsgSubscription.objects.all()
serializer_classes = {

View File

@ -5,7 +5,7 @@ from rest_framework.decorators import action
from common.http import is_true
from common.permissions import IsValidUser
from common.const.http import GET, PATCH, POST
from common.drf.api import JmsGenericViewSet
from common.drf.api import JMSGenericViewSet
from ..serializers import (
SiteMessageDetailSerializer, SiteMessageIdsSerializer,
SiteMessageSendSerializer,
@ -16,7 +16,7 @@ from ..filters import SiteMsgFilter
__all__ = ('SiteMessageViewSet', )
class SiteMessageViewSet(ListModelMixin, RetrieveModelMixin, JmsGenericViewSet):
class SiteMessageViewSet(ListModelMixin, RetrieveModelMixin, JMSGenericViewSet):
permission_classes = (IsValidUser,)
serializer_classes = {
'default': SiteMessageDetailSerializer,

View File

@ -5,6 +5,7 @@ from .dingtalk import DingTalk
from .email import Email
from .site_msg import SiteMessage
from .wecom import WeCom
from .feishu import FeiShu
class BACKEND(models.TextChoices):
@ -12,6 +13,7 @@ class BACKEND(models.TextChoices):
WECOM = 'wecom', _('WeCom')
DINGTALK = 'dingtalk', _('DingTalk')
SITE_MSG = 'site_msg', _('Site message')
FEISHU = 'feishu', _('FeiShu')
@property
def client(self):
@ -19,7 +21,8 @@ class BACKEND(models.TextChoices):
self.EMAIL: Email,
self.WECOM: WeCom,
self.DINGTALK: DingTalk,
self.SITE_MSG: SiteMessage
self.SITE_MSG: SiteMessage,
self.FEISHU: FeiShu,
}[self]
return client

View File

@ -1,5 +1,4 @@
from django.conf import settings
from common.message.backends.dingtalk import DingTalk as Client
from .base import BackendBase

View File

@ -0,0 +1,19 @@
from django.conf import settings
from common.message.backends.feishu import FeiShu as Client
from .base import BackendBase
class FeiShu(BackendBase):
account_field = 'feishu_id'
is_enable_field_in_settings = 'AUTH_FEISHU'
def __init__(self):
self.client = Client(
app_id=settings.FEISHU_APP_ID,
app_secret=settings.FEISHU_APP_SECRET
)
def send_msg(self, users, msg):
accounts, __, __ = self.get_accounts(users)
return self.client.send_text(accounts, msg)

View File

@ -92,8 +92,9 @@ class Message(metaclass=MessageType):
def get_email_msg(self) -> dict:
msg = self.get_common_msg()
subject = f'{msg[:20]} ...' if len(msg) >= 20 else msg
return {
'subject': msg,
'subject': subject,
'message': msg
}

View File

@ -4,8 +4,9 @@ from notifications.notifications import SystemMessage
from notifications.models import SystemMsgSubscription
from users.models import User
from notifications.backends import BACKEND
from terminal.models import Status, Terminal
__all__ = ('ServerPerformanceMessage',)
__all__ = ('ServerPerformanceMessage', 'ServerPerformanceCheckUtil')
class ServerPerformanceMessage(SystemMessage):
@ -13,13 +14,11 @@ class ServerPerformanceMessage(SystemMessage):
category_label = _('Operations')
message_type_label = _('Server performance')
def __init__(self, path, usage):
self.path = path
self.usage = usage
def __init__(self, msg):
self._msg = msg
def get_common_msg(self):
msg = _("Disk used more than 80%: {} => {}").format(self.path, self.usage.percent)
return msg
return self._msg
@classmethod
def post_insert_to_db(cls, subscription: SystemMsgSubscription):
@ -27,3 +26,78 @@ class ServerPerformanceMessage(SystemMessage):
subscription.users.add(*admins)
subscription.receive_backends = [BACKEND.EMAIL]
subscription.save()
class ServerPerformanceCheckUtil(object):
items_mapper = {
'is_alive': {
'default': False,
'max_threshold': False,
'alarm_msg_format': _('[Alive] The terminal is offline: {name}')
},
'disk_usage': {
'default': 0,
'max_threshold': 80,
'alarm_msg_format': _(
'[Disk] Disk used more than {max_threshold}%: => {value} ({name})'
)
},
'memory_usage': {
'default': 0,
'max_threshold': 85,
'alarm_msg_format': _(
'[Memory] Memory used more than {max_threshold}%: => {value} ({name})'
),
},
'cpu_load': {
'default': 0,
'max_threshold': 5,
'alarm_msg_format': _(
'[CPU] CPU load more than {max_threshold}: => {value} ({name})'
),
},
}
def __init__(self):
self.alarm_messages = []
self._terminals = []
self._terminal = None
def check_and_publish(self):
self.check()
self.publish()
def check(self):
self.alarm_messages = []
self.initial_terminals()
for item, data in self.items_mapper.items():
for self._terminal in self._terminals:
self.check_item(item, data)
def check_item(self, item, data):
default = data['default']
max_threshold = data['max_threshold']
value = getattr(self._terminal.stat, item, default)
print(value, max_threshold, self._terminal.name, self._terminal.id)
if isinstance(value, bool) and value != max_threshold:
return
elif isinstance(value, (int, float)) and value < max_threshold:
return
msg = data['alarm_msg_format']
msg = msg.format(max_threshold=max_threshold, value=value, name=self._terminal.name)
self.alarm_messages.append(msg)
def publish(self):
if not self.alarm_messages:
return
msg = '<br>'.join(self.alarm_messages)
ServerPerformanceMessage(msg).publish()
def initial_terminals(self):
terminals = []
for terminal in Terminal.objects.filter(is_deleted=False):
if not terminal.is_active:
continue
terminal.stat = Status.get_terminal_latest_stat(terminal)
terminals.append(terminal)
self._terminals = terminals

View File

@ -9,7 +9,7 @@ from celery.exceptions import SoftTimeLimitExceeded
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from common.utils import get_logger, get_object_or_none, get_disk_usage, get_log_keep_day
from common.utils import get_logger, get_object_or_none, get_log_keep_day
from orgs.utils import tmp_to_root_org, tmp_to_org
from .celery.decorator import (
register_as_period_task, after_app_shutdown_clean_periodic,
@ -20,7 +20,7 @@ from .celery.utils import (
disable_celery_periodic_task, delete_celery_periodic_task
)
from .models import Task, CommandExecution, CeleryTask
from .notifications import ServerPerformanceMessage
from .notifications import ServerPerformanceCheckUtil
logger = get_logger(__file__)
@ -132,18 +132,7 @@ def create_or_update_registered_periodic_tasks():
@shared_task
@register_as_period_task(interval=3600)
def check_server_performance_period():
if not settings.DISK_CHECK_ENABLED:
return
usages = get_disk_usage()
uncheck_paths = ['/etc', '/boot']
for path, usage in usages.items():
need_check = True
for uncheck_path in uncheck_paths:
if path.startswith(uncheck_path):
need_check = False
if need_check and usage.percent > 80:
ServerPerformanceMessage(path=path, usage=usage).publish()
ServerPerformanceCheckUtil().check_and_publish()
@shared_task(queue="ansible")

View File

@ -49,6 +49,9 @@ class OrgModelMixin(models.Model):
def save(self, *args, **kwargs):
org = get_current_org()
# 这里不可以优化成, 因为 root 组织下可以设置组织 id 来保存
# if org.is_root() and not self.org_id:
# raise ...
if org.is_root():
if not self.org_id:
raise ValidationError('Please save in a organization')

View File

@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from common.utils import lazyproperty, settings
from common.const import choices
from common.tree import TreeNode
from common.db.models import TextChoices
@ -233,6 +234,20 @@ class Organization(models.Model):
with tmp_to_org(self):
return resource_model.objects.all().count()
def as_tree_node(self, pid, opened=True):
node = TreeNode(**{
'id': str(self.id),
'name': self.name,
'title': self.name,
'pId': pid,
'open': opened,
'isParent': True,
'meta': {
'type': 'org'
}
})
return node
def _convert_to_uuid_set(users):
rst = set()

Some files were not shown because too many files have changed in this diff Show More