mirror of https://github.com/jumpserver/jumpserver
commit
c10436de47
|
@ -0,0 +1,9 @@
|
|||
#### What this PR does / why we need it?
|
||||
|
||||
#### Summary of your change
|
||||
|
||||
#### Please indicate you've done the following:
|
||||
|
||||
- [ ] Made sure tests are passing and test coverage is added if needed.
|
||||
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
|
||||
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
run: |
|
||||
TAG=$(basename ${GITHUB_REF})
|
||||
VERSION=${TAG/v/}
|
||||
wget https://raw.githubusercontent.com/jumpserver/installer/v${VERSION}/quick_start.sh
|
||||
wget https://raw.githubusercontent.com/jumpserver/installer/master/quick_start.sh
|
||||
sed -i "s@Version=.*@Version=v${VERSION}@g" quick_start.sh
|
||||
echo "::set-output name=TAG::$TAG"
|
||||
echo "::set-output name=VERSION::$VERSION"
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
|
@ -0,0 +1,25 @@
|
|||
# Contributing
|
||||
|
||||
## Create pull request
|
||||
PR are always welcome, even if they only contain small fixes like typos or a few lines of code. If there will be a significant effort, please document it as an issue and get a discussion going before starting to work on it.
|
||||
|
||||
Please submit a PR broken down into small changes bit by bit. A PR consisting of a lot features and code changes may be hard to review. It is recommended to submit PRs in an incremental fashion.
|
||||
|
||||
This [development guideline](https://docs.jumpserver.org/zh/master/dev/rest_api/) contains information about repository structure, how to setup development environment, how to run it, and more.
|
||||
|
||||
Note: If you split your pull request to small changes, please make sure any of the changes goes to master will not break anything. Otherwise, it can not be merged until this feature complete.
|
||||
|
||||
## Report issues
|
||||
It is a great way to contribute by reporting an issue. Well-written and complete bug reports are always welcome! Please open an issue and follow the template to fill in required information.
|
||||
|
||||
Before opening any issue, please look up the existing issues to avoid submitting a duplication.
|
||||
If you find a match, you can "subscribe" to it to get notified on updates. If you have additional helpful information about the issue, please leave a comment.
|
||||
|
||||
When reporting issues, always include:
|
||||
|
||||
* Which version you are using.
|
||||
* Steps to reproduce the issue.
|
||||
* Snapshots or log files if needed
|
||||
|
||||
Because the issues are open to the public, when submitting files, be sure to remove any sensitive information, e.g. user name, password, IP address, and company name. You can
|
||||
replace those parts with "REDACTED" or other strings like "****".
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.1.13 on 2022-02-08 02:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0084_auto_20220112_1959'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='commandfilterrule',
|
||||
name='ignore_case',
|
||||
field=models.BooleanField(default=True, verbose_name='Ignore case'),
|
||||
),
|
||||
]
|
|
@ -85,6 +85,7 @@ class CommandFilterRule(OrgModelMixin):
|
|||
validators=[MinValueValidator(1), MaxValueValidator(100)]
|
||||
)
|
||||
content = models.TextField(verbose_name=_("Content"), help_text=_("One line one command"))
|
||||
ignore_case = models.BooleanField(default=True, verbose_name=_('Ignore case'))
|
||||
action = models.IntegerField(default=ActionChoices.deny, choices=ActionChoices.choices, verbose_name=_("Action"))
|
||||
# 动作: 附加字段
|
||||
# - confirm: 命令复核人
|
||||
|
@ -129,13 +130,16 @@ class CommandFilterRule(OrgModelMixin):
|
|||
regex.append(r'\b{0}\b'.format(cmd))
|
||||
else:
|
||||
regex.append(r'\b{0}'.format(cmd))
|
||||
s = r'(?i){}'.format('|'.join(regex))
|
||||
s = r'{}'.format('|'.join(regex))
|
||||
return s
|
||||
|
||||
@staticmethod
|
||||
def compile_regex(regex):
|
||||
def compile_regex(regex, ignore_case):
|
||||
try:
|
||||
pattern = re.compile(regex)
|
||||
if ignore_case:
|
||||
pattern = re.compile(regex, re.IGNORECASE)
|
||||
else:
|
||||
pattern = re.compile(regex)
|
||||
except Exception as e:
|
||||
error = _('The generated regular expression is incorrect: {}').format(str(e))
|
||||
logger.error(error)
|
||||
|
@ -143,7 +147,7 @@ class CommandFilterRule(OrgModelMixin):
|
|||
return True, '', pattern
|
||||
|
||||
def match(self, data):
|
||||
succeed, error, pattern = self.compile_regex(regex=self.pattern)
|
||||
succeed, error, pattern = self.compile_regex(self.pattern, self.ignore_case)
|
||||
if not succeed:
|
||||
return self.ACTION_UNKNOWN, ''
|
||||
|
||||
|
|
|
@ -150,17 +150,37 @@ class AuthMixin:
|
|||
|
||||
def load_asset_special_auth(self, asset, username=''):
|
||||
"""
|
||||
AuthBook 的数据状态
|
||||
| asset | systemuser | username |
|
||||
1 | * | * | x |
|
||||
2 | * | x | * |
|
||||
|
||||
当前 AuthBook 只有以上两种状态,systemuser 与 username 不会并存。
|
||||
正常的资产与系统用户关联产生的是第1种状态,改密则产生第2种状态。改密之后
|
||||
只有 username 而没有 systemuser 。
|
||||
|
||||
Freq: 关联同一资产的多个系统用户指定同一用户名时,修改用户密码会影响所有系统用户
|
||||
|
||||
这里有一个不对称的行为,同名系统用户密码覆盖
|
||||
当有相同 username 的多个系统用户时,有改密动作之后,所有的同名系统用户都使用最后
|
||||
一次改动,但如果没有发生过改密,同名系统用户使用的密码还是各自的。
|
||||
|
||||
"""
|
||||
authbooks = list(AuthBook.objects.filter(asset=asset).filter(
|
||||
Q(username=username) | Q(systemuser=self)
|
||||
))
|
||||
if len(authbooks) == 0:
|
||||
if username == '':
|
||||
username = self.username
|
||||
|
||||
authbook = AuthBook.objects.filter(
|
||||
asset=asset, username=username, systemuser__isnull=True
|
||||
).order_by('-date_created').first()
|
||||
|
||||
if not authbook:
|
||||
authbook = AuthBook.objects.filter(
|
||||
asset=asset, systemuser=self
|
||||
).order_by('-date_created').first()
|
||||
|
||||
if not authbook:
|
||||
return None
|
||||
elif len(authbooks) == 1:
|
||||
authbook = authbooks[0]
|
||||
else:
|
||||
authbooks.sort(key=lambda x: 1 if x.username == username else 0, reverse=True)
|
||||
authbook = authbooks[0]
|
||||
|
||||
authbook.load_auth()
|
||||
self.password = authbook.password
|
||||
self.private_key = authbook.private_key
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
#
|
||||
from io import StringIO
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.utils import ssh_pubkey_gen, ssh_private_key_gen, validate_ssh_private_key
|
||||
|
|
|
@ -38,7 +38,7 @@ class CommandFilterRuleSerializer(BulkOrgResourceModelSerializer):
|
|||
model = CommandFilterRule
|
||||
fields_mini = ['id']
|
||||
fields_small = fields_mini + [
|
||||
'type', 'type_display', 'content', 'pattern', 'priority',
|
||||
'type', 'type_display', 'content', 'ignore_case', 'pattern', 'priority',
|
||||
'action', 'action_display', 'reviewers',
|
||||
'date_created', 'date_updated',
|
||||
'comment', 'created_by',
|
||||
|
@ -66,7 +66,8 @@ class CommandFilterRuleSerializer(BulkOrgResourceModelSerializer):
|
|||
regex = CommandFilterRule.construct_command_regex(content)
|
||||
else:
|
||||
regex = content
|
||||
succeed, error, pattern = CommandFilterRule.compile_regex(regex)
|
||||
ignore_case = self.initial_data.get('ignore_case')
|
||||
succeed, error, pattern = CommandFilterRule.compile_regex(regex, ignore_case)
|
||||
if not succeed:
|
||||
raise serializers.ValidationError(error)
|
||||
return content
|
||||
|
|
|
@ -79,7 +79,7 @@ def subscribe_node_assets_mapping_expire(sender, **kwargs):
|
|||
Node.expire_node_all_asset_ids_mapping_from_memory(root_org_id)
|
||||
|
||||
def keep_subscribe_node_assets_relation():
|
||||
node_assets_mapping_for_memory_pub_sub.keep_handle_msg(handle_node_relation_change)
|
||||
node_assets_mapping_for_memory_pub_sub.subscribe(handle_node_relation_change)
|
||||
|
||||
t = threading.Thread(target=keep_subscribe_node_assets_relation)
|
||||
t.daemon = True
|
||||
|
|
|
@ -4,10 +4,10 @@ from itertools import groupby
|
|||
from celery import shared_task
|
||||
from common.db.utils import get_object_if_need, get_objects
|
||||
from django.utils.translation import ugettext as _, gettext_noop
|
||||
from django.db.models import Empty, Q
|
||||
from django.db.models import Empty
|
||||
|
||||
from common.utils import encrypt_password, get_logger
|
||||
from assets.models import SystemUser, Asset, AuthBook
|
||||
from assets.models import SystemUser, Asset
|
||||
from orgs.utils import org_aware_func, tmp_to_root_org
|
||||
from . import const
|
||||
from .utils import clean_ansible_task_hosts, group_asset_by_platform
|
||||
|
@ -178,6 +178,7 @@ def get_push_windows_system_user_tasks(system_user: SystemUser, username=None):
|
|||
|
||||
def get_push_system_user_tasks(system_user, platform="unixlike", username=None):
|
||||
"""
|
||||
获取推送系统用户的 ansible 命令,跟资产无关
|
||||
:param system_user:
|
||||
:param platform:
|
||||
:param username: 当动态时,近推送某个
|
||||
|
@ -209,18 +210,10 @@ def push_system_user_util(system_user, assets, task_name, username=None):
|
|||
if not assets:
|
||||
return {}
|
||||
|
||||
# 资产按平台分类
|
||||
assets_sorted = sorted(assets, key=group_asset_by_platform)
|
||||
platform_hosts = groupby(assets_sorted, key=group_asset_by_platform)
|
||||
|
||||
def run_task(_tasks, _hosts):
|
||||
if not _tasks:
|
||||
return
|
||||
task, created = update_or_create_ansible_task(
|
||||
task_name=task_name, hosts=_hosts, tasks=_tasks, pattern='all',
|
||||
options=const.TASK_OPTIONS, run_as_admin=True,
|
||||
)
|
||||
task.run()
|
||||
|
||||
if system_user.username_same_with_user:
|
||||
if username is None:
|
||||
# 动态系统用户,但是没有指定 username
|
||||
|
@ -232,6 +225,15 @@ def push_system_user_util(system_user, assets, task_name, username=None):
|
|||
assert username is None, 'Only Dynamic user can assign `username`'
|
||||
usernames = [system_user.username]
|
||||
|
||||
def run_task(_tasks, _hosts):
|
||||
if not _tasks:
|
||||
return
|
||||
task, created = update_or_create_ansible_task(
|
||||
task_name=task_name, hosts=_hosts, tasks=_tasks, pattern='all',
|
||||
options=const.TASK_OPTIONS, run_as_admin=True,
|
||||
)
|
||||
task.run()
|
||||
|
||||
for platform, _assets in platform_hosts:
|
||||
_assets = list(_assets)
|
||||
if not _assets:
|
||||
|
@ -239,36 +241,11 @@ def push_system_user_util(system_user, assets, task_name, username=None):
|
|||
print(_("Start push system user for platform: [{}]").format(platform))
|
||||
print(_("Hosts count: {}").format(len(_assets)))
|
||||
|
||||
id_asset_map = {_asset.id: _asset for _asset in _assets}
|
||||
asset_ids = id_asset_map.keys()
|
||||
no_special_auth = []
|
||||
special_auth_set = set()
|
||||
|
||||
auth_books = AuthBook.objects.filter(asset_id__in=asset_ids).filter(
|
||||
Q(username__in=usernames) | Q(systemuser=system_user)
|
||||
).prefetch_related('systemuser')
|
||||
|
||||
for auth_book in auth_books:
|
||||
auth_book.load_auth()
|
||||
special_auth_set.add((auth_book.username, auth_book.asset_id))
|
||||
|
||||
for _username in usernames:
|
||||
no_special_assets = []
|
||||
for asset_id in asset_ids:
|
||||
if (_username, asset_id) not in special_auth_set:
|
||||
no_special_assets.append(id_asset_map[asset_id])
|
||||
if no_special_assets:
|
||||
no_special_auth.append((_username, no_special_assets))
|
||||
|
||||
for _username, no_special_assets in no_special_auth:
|
||||
tasks = get_push_system_user_tasks(system_user, platform, username=_username)
|
||||
run_task(tasks, no_special_assets)
|
||||
|
||||
for auth_book in auth_books:
|
||||
system_user._merge_auth(auth_book)
|
||||
tasks = get_push_system_user_tasks(system_user, platform, username=auth_book.username)
|
||||
asset = id_asset_map[auth_book.asset_id]
|
||||
run_task(tasks, [asset])
|
||||
for u in usernames:
|
||||
for a in _assets:
|
||||
system_user.load_asset_special_auth(a, u)
|
||||
tasks = get_push_system_user_tasks(system_user, platform, username=u)
|
||||
run_task(tasks, [a])
|
||||
|
||||
|
||||
@shared_task(queue="ansible")
|
||||
|
|
|
@ -22,13 +22,15 @@ from users.models import User
|
|||
from users.signals import post_user_change_password
|
||||
from terminal.models import Session, Command
|
||||
from .utils import write_login_log
|
||||
from . import models
|
||||
from . import models, serializers
|
||||
from .models import OperateLog
|
||||
from orgs.utils import current_org
|
||||
from perms.models import AssetPermission, ApplicationPermission
|
||||
from terminal.backends.command.serializers import SessionCommandSerializer
|
||||
from terminal.serializers import SessionSerializer
|
||||
from common.const.signals import POST_ADD, POST_REMOVE, POST_CLEAR
|
||||
from common.utils import get_request_ip, get_logger, get_syslogger
|
||||
from common.utils.encode import model_to_json
|
||||
from common.utils.encode import data_to_json
|
||||
|
||||
logger = get_logger(__name__)
|
||||
sys_logger = get_syslogger(__name__)
|
||||
|
@ -255,20 +257,27 @@ def on_user_change_password(sender, user=None, **kwargs):
|
|||
def on_audits_log_create(sender, instance=None, **kwargs):
|
||||
if sender == models.UserLoginLog:
|
||||
category = "login_log"
|
||||
serializer_cls = serializers.UserLoginLogSerializer
|
||||
elif sender == models.FTPLog:
|
||||
category = "ftp_log"
|
||||
serializer_cls = serializers.FTPLogSerializer
|
||||
elif sender == models.OperateLog:
|
||||
category = "operation_log"
|
||||
serializer_cls = serializers.OperateLogSerializer
|
||||
elif sender == models.PasswordChangeLog:
|
||||
category = "password_change_log"
|
||||
serializer_cls = serializers.PasswordChangeLogSerializer
|
||||
elif sender == Session:
|
||||
category = "host_session_log"
|
||||
serializer_cls = SessionSerializer
|
||||
elif sender == Command:
|
||||
category = "session_command_log"
|
||||
serializer_cls = SessionCommandSerializer
|
||||
else:
|
||||
return
|
||||
|
||||
data = model_to_json(instance, indent=None)
|
||||
serializer = serializer_cls(instance)
|
||||
data = data_to_json(serializer.data, indent=None)
|
||||
msg = "{} - {}".format(category, data)
|
||||
sys_logger.info(msg)
|
||||
|
||||
|
|
|
@ -298,9 +298,6 @@ class SecretDetailMixin:
|
|||
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)
|
||||
|
||||
|
|
|
@ -17,7 +17,9 @@ from .signals import post_auth_success, post_auth_failed
|
|||
@receiver(user_logged_in)
|
||||
def on_user_auth_login_success(sender, user, request, **kwargs):
|
||||
# 开启了 MFA,且没有校验过, 可以全局校验, middleware 中可以全局管理 oidc 等第三方认证的 MFA
|
||||
if user.mfa_enabled and not request.session.get('auth_mfa'):
|
||||
if settings.SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY \
|
||||
and user.mfa_enabled \
|
||||
and not request.session.get('auth_mfa'):
|
||||
request.session['auth_mfa_required'] = 1
|
||||
|
||||
# 单点登录,超过了自动退出
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import threading
|
||||
|
||||
import redis
|
||||
from django.conf import settings
|
||||
|
@ -19,49 +20,84 @@ def get_redis_client(db):
|
|||
return rc
|
||||
|
||||
|
||||
class Subscription:
|
||||
def __init__(self, ch, sub, ):
|
||||
self.ch = ch
|
||||
self.sub = sub
|
||||
|
||||
def _handle_msg(self, _next, error, complete):
|
||||
"""
|
||||
handle arg is the pub published
|
||||
|
||||
:param _next: next msg handler
|
||||
:param error: error msg handler
|
||||
:param complete: complete msg handler
|
||||
:return:
|
||||
"""
|
||||
msgs = self.sub.listen()
|
||||
|
||||
if error is None:
|
||||
error = lambda m, i: None
|
||||
|
||||
if complete is None:
|
||||
complete = lambda: None
|
||||
|
||||
try:
|
||||
for msg in msgs:
|
||||
if msg["type"] != "message":
|
||||
continue
|
||||
item = None
|
||||
try:
|
||||
item_json = msg['data'].decode()
|
||||
item = json.loads(item_json)
|
||||
|
||||
with safe_db_connection():
|
||||
_next(item)
|
||||
except Exception as e:
|
||||
error(msg, item)
|
||||
logger.error('Subscribe handler handle msg error: ', e)
|
||||
except Exception as e:
|
||||
logger.error('Consume msg error: ', e)
|
||||
|
||||
try:
|
||||
complete()
|
||||
except Exception as e:
|
||||
logger.error('Complete subscribe error: {}'.format(e))
|
||||
pass
|
||||
|
||||
try:
|
||||
self.unsubscribe()
|
||||
except Exception as e:
|
||||
logger.error("Redis observer close error: {}".format(e))
|
||||
|
||||
def keep_handle_msg(self, _next, error, complete):
|
||||
t = threading.Thread(target=self._handle_msg, args=(_next, error, complete))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
return t
|
||||
|
||||
def unsubscribe(self):
|
||||
try:
|
||||
self.sub.close()
|
||||
except Exception as e:
|
||||
logger.error('Unsubscribe msg error: {}'.format(e))
|
||||
|
||||
|
||||
class RedisPubSub:
|
||||
def __init__(self, ch, db=10):
|
||||
self.ch = ch
|
||||
self.redis = get_redis_client(db)
|
||||
|
||||
def subscribe(self):
|
||||
def subscribe(self, _next, error=None, complete=None):
|
||||
ps = self.redis.pubsub()
|
||||
ps.subscribe(self.ch)
|
||||
return ps
|
||||
sub = Subscription(self.ch, ps)
|
||||
sub.keep_handle_msg(_next, error, complete)
|
||||
return sub
|
||||
|
||||
def publish(self, data):
|
||||
data_json = json.dumps(data)
|
||||
self.redis.publish(self.ch, data_json)
|
||||
return True
|
||||
|
||||
def keep_handle_msg(self, handle):
|
||||
"""
|
||||
handle arg is the pub published
|
||||
|
||||
:param handle: lambda item: do_something
|
||||
:return:
|
||||
"""
|
||||
sub = self.subscribe()
|
||||
msgs = sub.listen()
|
||||
|
||||
try:
|
||||
for msg in msgs:
|
||||
if msg["type"] != "message":
|
||||
continue
|
||||
try:
|
||||
item_json = msg['data'].decode()
|
||||
item = json.loads(item_json)
|
||||
|
||||
with safe_db_connection():
|
||||
handle(item)
|
||||
except Exception as e:
|
||||
logger.error('Subscribe handler handle msg error: ', e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Consume msg error: ', e)
|
||||
|
||||
try:
|
||||
sub.close()
|
||||
except Exception as e:
|
||||
logger.error("Redis observer close error: ", e)
|
||||
|
||||
|
||||
|
|
|
@ -208,30 +208,7 @@ def ensure_last_char_is_ascii(data):
|
|||
secret_pattern = re.compile(r'password|secret|key', re.IGNORECASE)
|
||||
|
||||
|
||||
def model_to_dict_pro(instance, fields=None, exclude=None):
|
||||
from ..fields.model import EncryptMixin
|
||||
opts = instance._meta
|
||||
data = {}
|
||||
for f in chain(opts.concrete_fields, opts.private_fields):
|
||||
if not getattr(f, 'editable', False):
|
||||
continue
|
||||
if fields and f.name not in fields:
|
||||
continue
|
||||
if exclude and f.name in exclude:
|
||||
continue
|
||||
if isinstance(f, FileField):
|
||||
continue
|
||||
if isinstance(f, EncryptMixin):
|
||||
continue
|
||||
if secret_pattern.search(f.name):
|
||||
continue
|
||||
value = f.value_from_object(instance)
|
||||
data[f.name] = value
|
||||
return data
|
||||
|
||||
|
||||
def model_to_json(instance, sort_keys=True, indent=2, cls=None):
|
||||
data = model_to_dict_pro(instance)
|
||||
def data_to_json(data, sort_keys=True, indent=2, cls=None):
|
||||
if cls is None:
|
||||
cls = DjangoJSONEncoder
|
||||
return json.dumps(data, sort_keys=sort_keys, indent=indent, cls=cls)
|
||||
|
|
|
@ -312,6 +312,7 @@ class Config(dict):
|
|||
|
||||
# 安全配置
|
||||
'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启
|
||||
'SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY': True,
|
||||
'SECURITY_COMMAND_EXECUTION': True,
|
||||
'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True,
|
||||
'SECURITY_VIEW_AUTH_NEED_MFA': True,
|
||||
|
|
|
@ -32,6 +32,7 @@ TERMINAL_REPLAY_STORAGE = CONFIG.TERMINAL_REPLAY_STORAGE
|
|||
|
||||
# Security settings
|
||||
SECURITY_MFA_AUTH = CONFIG.SECURITY_MFA_AUTH
|
||||
SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY = CONFIG.SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY
|
||||
SECURITY_MAX_IDLE_TIME = CONFIG.SECURITY_MAX_IDLE_TIME # Unit: minute
|
||||
SECURITY_COMMAND_EXECUTION = CONFIG.SECURITY_COMMAND_EXECUTION
|
||||
SECURITY_PASSWORD_EXPIRATION_TIME = CONFIG.SECURITY_PASSWORD_EXPIRATION_TIME # Unit: day
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:07244b630278a5574b97c46218ae453de71d01a1ea6682b88baa741a99cf8c22
|
||||
size 97436
|
||||
oid sha256:8a421482ff4103a9c3ca895b29e739c2cef0dc10a4f9914bfe7226fa3c45cac4
|
||||
size 97592
|
||||
|
|
|
@ -7,7 +7,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: JumpServer 0.3.3\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-01-20 10:38+0800\n"
|
||||
"POT-Creation-Date: 2022-02-08 17:40+0800\n"
|
||||
"PO-Revision-Date: 2021-05-20 10:54+0800\n"
|
||||
"Last-Translator: ibuler <ibuler@qq.com>\n"
|
||||
"Language-Team: JumpServer team<ibuler@qq.com>\n"
|
||||
|
@ -55,7 +55,7 @@ msgstr "激活中"
|
|||
#: assets/models/asset.py:144 assets/models/asset.py:232
|
||||
#: assets/models/backup.py:54 assets/models/base.py:180
|
||||
#: assets/models/cluster.py:29 assets/models/cmd_filter.py:48
|
||||
#: assets/models/cmd_filter.py:95 assets/models/domain.py:25
|
||||
#: assets/models/cmd_filter.py:96 assets/models/domain.py:25
|
||||
#: assets/models/domain.py:65 assets/models/group.py:23
|
||||
#: assets/models/label.py:23 ops/models/adhoc.py:38 orgs/models.py:27
|
||||
#: perms/models/base.py:93 settings/models.py:34 terminal/models/storage.py:26
|
||||
|
@ -104,7 +104,7 @@ msgstr "规则"
|
|||
|
||||
#: acls/models/login_acl.py:31 acls/models/login_asset_acl.py:26
|
||||
#: acls/serializers/login_acl.py:17 acls/serializers/login_asset_acl.py:75
|
||||
#: assets/models/cmd_filter.py:88 audits/models.py:58 audits/serializers.py:51
|
||||
#: assets/models/cmd_filter.py:89 audits/models.py:58 audits/serializers.py:51
|
||||
#: authentication/templates/authentication/_access_key_modal.html:34
|
||||
#: users/templates/users/_granted_assets.html:29
|
||||
#: users/templates/users/user_asset_permission.html:44
|
||||
|
@ -114,7 +114,7 @@ msgid "Action"
|
|||
msgstr "动作"
|
||||
|
||||
#: acls/models/login_acl.py:35 acls/models/login_asset_acl.py:32
|
||||
#: acls/serializers/login_acl.py:16 assets/models/cmd_filter.py:93
|
||||
#: acls/serializers/login_acl.py:16 assets/models/cmd_filter.py:94
|
||||
msgid "Reviewers"
|
||||
msgstr "审批人"
|
||||
|
||||
|
@ -124,7 +124,7 @@ msgstr "登录访问控制"
|
|||
|
||||
#: acls/models/login_asset_acl.py:21
|
||||
#: applications/serializers/application.py:122
|
||||
#: applications/serializers/application.py:165
|
||||
#: applications/serializers/application.py:166
|
||||
msgid "System User"
|
||||
msgstr "系统用户"
|
||||
|
||||
|
@ -368,7 +368,7 @@ msgid "Date updated"
|
|||
msgstr "更新日期"
|
||||
|
||||
#: applications/serializers/application.py:121
|
||||
#: applications/serializers/application.py:164
|
||||
#: applications/serializers/application.py:165
|
||||
msgid "Application display"
|
||||
msgstr "应用名称"
|
||||
|
||||
|
@ -595,7 +595,7 @@ msgstr "标签管理"
|
|||
|
||||
#: assets/models/asset.py:230 assets/models/base.py:183
|
||||
#: assets/models/cluster.py:28 assets/models/cmd_filter.py:52
|
||||
#: assets/models/cmd_filter.py:98 assets/models/group.py:21
|
||||
#: assets/models/cmd_filter.py:99 assets/models/group.py:21
|
||||
#: common/db/models.py:111 common/mixins/models.py:49 orgs/models.py:25
|
||||
#: orgs/models.py:437 perms/models/base.py:91 users/models/user.py:593
|
||||
#: users/serializers/group.py:33
|
||||
|
@ -817,15 +817,19 @@ msgstr "内容"
|
|||
msgid "One line one command"
|
||||
msgstr "每行一个命令"
|
||||
|
||||
#: assets/models/cmd_filter.py:102
|
||||
#: assets/models/cmd_filter.py:88
|
||||
msgid "Ignore case"
|
||||
msgstr "忽略大小写"
|
||||
|
||||
#: assets/models/cmd_filter.py:103
|
||||
msgid "Command filter rule"
|
||||
msgstr "命令过滤规则"
|
||||
|
||||
#: assets/models/cmd_filter.py:140
|
||||
#: assets/models/cmd_filter.py:144
|
||||
msgid "The generated regular expression is incorrect: {}"
|
||||
msgstr "生成的正则表达式有误"
|
||||
|
||||
#: assets/models/cmd_filter.py:166 tickets/const.py:13
|
||||
#: assets/models/cmd_filter.py:170 tickets/const.py:13
|
||||
msgid "Command confirm"
|
||||
msgstr "命令复核"
|
||||
|
||||
|
@ -3521,34 +3525,42 @@ msgid "Global MFA auth"
|
|||
msgstr "全局启用 MFA 认证"
|
||||
|
||||
#: settings/serializers/security.py:47
|
||||
msgid "Third-party login users perform MFA authentication"
|
||||
msgstr "第三方登录用户进行MFA认证"
|
||||
|
||||
#: settings/serializers/security.py:48
|
||||
msgid "The third-party login modes include OIDC, CAS, and SAML2"
|
||||
msgstr "第三方登录方式包括: OIDC、CAS、SAML2"
|
||||
|
||||
#: settings/serializers/security.py:52
|
||||
msgid "Limit the number of user login failures"
|
||||
msgstr "限制用户登录失败次数"
|
||||
|
||||
#: settings/serializers/security.py:51
|
||||
#: settings/serializers/security.py:56
|
||||
msgid "Block user login interval"
|
||||
msgstr "禁止用户登录时间间隔"
|
||||
|
||||
#: settings/serializers/security.py:56
|
||||
#: settings/serializers/security.py:61
|
||||
msgid "Limit the number of IP login failures"
|
||||
msgstr "限制 IP 登录失败次数"
|
||||
|
||||
#: settings/serializers/security.py:60
|
||||
#: settings/serializers/security.py:65
|
||||
msgid "Block IP login interval"
|
||||
msgstr "禁止 IP 登录时间间隔"
|
||||
|
||||
#: settings/serializers/security.py:64
|
||||
#: settings/serializers/security.py:69
|
||||
msgid "Login IP White List"
|
||||
msgstr "IP 登录白名单"
|
||||
|
||||
#: settings/serializers/security.py:69
|
||||
#: settings/serializers/security.py:74
|
||||
msgid "Login IP Black List"
|
||||
msgstr "IP 登录黑名单"
|
||||
|
||||
#: settings/serializers/security.py:75
|
||||
#: settings/serializers/security.py:80
|
||||
msgid "User password expiration"
|
||||
msgstr "用户密码过期时间"
|
||||
|
||||
#: settings/serializers/security.py:77
|
||||
#: settings/serializers/security.py:82
|
||||
msgid ""
|
||||
"Unit: day, If the user does not update the password during the time, the "
|
||||
"user password will expire failure;The password expiration reminder mail will "
|
||||
|
@ -3558,55 +3570,55 @@ msgstr ""
|
|||
"单位:天, 如果用户在此期间没有更新密码,用户密码将过期失效; 密码过期提醒邮件"
|
||||
"将在密码过期前5天内由系统(每天)自动发送给用户"
|
||||
|
||||
#: settings/serializers/security.py:84
|
||||
#: settings/serializers/security.py:89
|
||||
msgid "Number of repeated historical passwords"
|
||||
msgstr "不能设置近几次密码"
|
||||
|
||||
#: settings/serializers/security.py:86
|
||||
#: settings/serializers/security.py:91
|
||||
msgid ""
|
||||
"Tip: When the user resets the password, it cannot be the previous n "
|
||||
"historical passwords of the user"
|
||||
msgstr "提示:用户重置密码时,不能为该用户前几次使用过的密码"
|
||||
|
||||
#: settings/serializers/security.py:91
|
||||
#: settings/serializers/security.py:96
|
||||
msgid "Only single device login"
|
||||
msgstr "仅一台设备登录"
|
||||
|
||||
#: settings/serializers/security.py:92
|
||||
#: settings/serializers/security.py:97
|
||||
msgid "Next device login, pre login will be logout"
|
||||
msgstr "下个设备登录,上次登录会被顶掉"
|
||||
|
||||
#: settings/serializers/security.py:95
|
||||
#: settings/serializers/security.py:100
|
||||
msgid "Only exist user login"
|
||||
msgstr "仅已存在用户登录"
|
||||
|
||||
#: settings/serializers/security.py:96
|
||||
#: settings/serializers/security.py:101
|
||||
msgid "If enable, CAS、OIDC auth will be failed, if user not exist yet"
|
||||
msgstr "开启后,如果系统中不存在该用户,CAS、OIDC 登录将会失败"
|
||||
|
||||
#: settings/serializers/security.py:99
|
||||
#: settings/serializers/security.py:104
|
||||
msgid "Only from source login"
|
||||
msgstr "仅从用户来源登录"
|
||||
|
||||
#: settings/serializers/security.py:100
|
||||
#: settings/serializers/security.py:105
|
||||
msgid "Only log in from the user source property"
|
||||
msgstr "开启后,如果用户来源为本地,CAS、OIDC 登录将会失败"
|
||||
|
||||
#: settings/serializers/security.py:104
|
||||
#: settings/serializers/security.py:109
|
||||
msgid "MFA verify TTL"
|
||||
msgstr "MFA 校验有效期"
|
||||
|
||||
#: settings/serializers/security.py:106
|
||||
#: settings/serializers/security.py:111
|
||||
msgid ""
|
||||
"Unit: second, The verification MFA takes effect only when you view the "
|
||||
"account password"
|
||||
msgstr "单位: 秒, 目前仅在查看账号密码校验 MFA 时生效"
|
||||
|
||||
#: settings/serializers/security.py:111
|
||||
#: settings/serializers/security.py:116
|
||||
msgid "Enable Login dynamic code"
|
||||
msgstr "启用登录附加码"
|
||||
|
||||
#: settings/serializers/security.py:112
|
||||
#: settings/serializers/security.py:117
|
||||
msgid ""
|
||||
"The password and additional code are sent to a third party authentication "
|
||||
"system for verification"
|
||||
|
@ -3614,89 +3626,89 @@ msgstr ""
|
|||
"密码和附加码一并发送给第三方认证系统进行校验, 如:有的第三方认证系统,需要 密"
|
||||
"码+6位数字 完成认证"
|
||||
|
||||
#: settings/serializers/security.py:117
|
||||
#: settings/serializers/security.py:122
|
||||
msgid "MFA in login page"
|
||||
msgstr "MFA 在登录页面输入"
|
||||
|
||||
#: settings/serializers/security.py:118
|
||||
#: settings/serializers/security.py:123
|
||||
msgid "Eu security regulations(GDPR) require MFA to be on the login page"
|
||||
msgstr "欧盟数据安全法规(GDPR) 要求 MFA 在登录页面,来确保系统登录安全"
|
||||
|
||||
#: settings/serializers/security.py:121
|
||||
#: settings/serializers/security.py:126
|
||||
msgid "Enable Login captcha"
|
||||
msgstr "启用登录验证码"
|
||||
|
||||
#: settings/serializers/security.py:122
|
||||
#: settings/serializers/security.py:127
|
||||
msgid "Enable captcha to prevent robot authentication"
|
||||
msgstr "开启验证码,防止机器人登录"
|
||||
|
||||
#: settings/serializers/security.py:142
|
||||
#: settings/serializers/security.py:147
|
||||
msgid "Enable terminal register"
|
||||
msgstr "终端注册"
|
||||
|
||||
#: settings/serializers/security.py:144
|
||||
#: settings/serializers/security.py:149
|
||||
msgid ""
|
||||
"Allow terminal register, after all terminal setup, you should disable this "
|
||||
"for security"
|
||||
msgstr "是否允许终端注册,当所有终端启动后,为了安全应该关闭"
|
||||
|
||||
#: settings/serializers/security.py:148
|
||||
#: settings/serializers/security.py:153
|
||||
msgid "Enable watermark"
|
||||
msgstr "开启水印"
|
||||
|
||||
#: settings/serializers/security.py:149
|
||||
#: settings/serializers/security.py:154
|
||||
msgid "Enabled, the web session and replay contains watermark information"
|
||||
msgstr "启用后,Web 会话和录像将包含水印信息"
|
||||
|
||||
#: settings/serializers/security.py:153
|
||||
#: settings/serializers/security.py:158
|
||||
msgid "Connection max idle time"
|
||||
msgstr "连接最大空闲时间"
|
||||
|
||||
#: settings/serializers/security.py:154
|
||||
#: settings/serializers/security.py:159
|
||||
msgid "If idle time more than it, disconnect connection Unit: minute"
|
||||
msgstr "提示:如果超过该配置没有操作,连接会被断开 (单位:分)"
|
||||
|
||||
#: settings/serializers/security.py:157
|
||||
#: settings/serializers/security.py:162
|
||||
msgid "Remember manual auth"
|
||||
msgstr "保存手动输入密码"
|
||||
|
||||
#: settings/serializers/security.py:160
|
||||
#: settings/serializers/security.py:165
|
||||
msgid "Enable change auth secure mode"
|
||||
msgstr "启用改密安全模式"
|
||||
|
||||
#: settings/serializers/security.py:163
|
||||
#: settings/serializers/security.py:168
|
||||
msgid "Insecure command alert"
|
||||
msgstr "危险命令告警"
|
||||
|
||||
#: settings/serializers/security.py:166
|
||||
#: settings/serializers/security.py:171
|
||||
msgid "Email recipient"
|
||||
msgstr "邮件收件人"
|
||||
|
||||
#: settings/serializers/security.py:167
|
||||
#: settings/serializers/security.py:172
|
||||
msgid "Multiple user using , split"
|
||||
msgstr "多个用户,使用 , 分割"
|
||||
|
||||
#: settings/serializers/security.py:170
|
||||
#: settings/serializers/security.py:175
|
||||
msgid "Batch command execution"
|
||||
msgstr "批量命令执行"
|
||||
|
||||
#: settings/serializers/security.py:171
|
||||
#: settings/serializers/security.py:176
|
||||
msgid "Allow user run batch command or not using ansible"
|
||||
msgstr "是否允许用户使用 ansible 执行批量命令"
|
||||
|
||||
#: settings/serializers/security.py:174
|
||||
#: settings/serializers/security.py:179
|
||||
msgid "Session share"
|
||||
msgstr "会话分享"
|
||||
|
||||
#: settings/serializers/security.py:175
|
||||
#: settings/serializers/security.py:180
|
||||
msgid "Enabled, Allows user active session to be shared with other users"
|
||||
msgstr "开启后允许用户分享已连接的资产会话给它人,协同工作"
|
||||
|
||||
#: settings/serializers/security.py:178
|
||||
#: settings/serializers/security.py:183
|
||||
msgid "Remote Login Protection"
|
||||
msgstr "异地登录保护"
|
||||
|
||||
#: settings/serializers/security.py:180
|
||||
#: settings/serializers/security.py:185
|
||||
msgid ""
|
||||
"The system determines whether the login IP address belongs to a common login "
|
||||
"city. If the account is logged in from a common login city, the system sends "
|
||||
|
|
|
@ -12,14 +12,13 @@ logger = get_logger(__name__)
|
|||
|
||||
class SiteMsgWebsocket(JsonWebsocketConsumer):
|
||||
refresh_every_seconds = 10
|
||||
sub = None
|
||||
|
||||
def connect(self):
|
||||
user = self.scope["user"]
|
||||
if user.is_authenticated:
|
||||
self.accept()
|
||||
|
||||
thread = threading.Thread(target=self.watch_recv_new_site_msg)
|
||||
thread.start()
|
||||
self.sub = self.watch_recv_new_site_msg()
|
||||
else:
|
||||
self.close()
|
||||
|
||||
|
@ -56,4 +55,9 @@ class SiteMsgWebsocket(JsonWebsocketConsumer):
|
|||
if user_id in users:
|
||||
ws.send_unread_msg_count()
|
||||
|
||||
new_site_msg_chan.keep_handle_msg(handle_new_site_msg_recv)
|
||||
return new_site_msg_chan.subscribe(handle_new_site_msg_recv)
|
||||
|
||||
def disconnect(self, code):
|
||||
if self.sub:
|
||||
self.sub.unsubscribe()
|
||||
|
||||
|
|
|
@ -46,7 +46,7 @@ def subscribe_orgs_mapping_expire(sender, **kwargs):
|
|||
logger.debug("Start subscribe for expire orgs mapping from memory")
|
||||
|
||||
def keep_subscribe_org_mapping():
|
||||
orgs_mapping_for_memory_pub_sub.keep_handle_msg(
|
||||
orgs_mapping_for_memory_pub_sub.subscribe(
|
||||
lambda org_id: Organization.expire_orgs_mapping()
|
||||
)
|
||||
|
||||
|
|
|
@ -42,6 +42,11 @@ class SecurityAuthSerializer(serializers.Serializer):
|
|||
),
|
||||
required=False, label=_("Global MFA auth")
|
||||
)
|
||||
SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY = serializers.BooleanField(
|
||||
required=False, default=True,
|
||||
label=_('Third-party login users perform MFA authentication'),
|
||||
help_text=_('The third-party login modes include OIDC, CAS, and SAML2'),
|
||||
)
|
||||
SECURITY_LOGIN_LIMIT_COUNT = serializers.IntegerField(
|
||||
min_value=3, max_value=99999,
|
||||
label=_('Limit the number of user login failures')
|
||||
|
|
|
@ -80,9 +80,7 @@ def subscribe_settings_change(sender, **kwargs):
|
|||
logger.debug("Start subscribe setting change")
|
||||
|
||||
def keep_subscribe_settings_change():
|
||||
setting_pub_sub.keep_handle_msg(
|
||||
lambda name: Setting.refresh_item(name)
|
||||
)
|
||||
setting_pub_sub.subscribe(lambda name: Setting.refresh_item(name))
|
||||
|
||||
t = threading.Thread(target=keep_subscribe_settings_change)
|
||||
t.daemon = True
|
||||
|
|
|
@ -12,7 +12,7 @@ from rest_framework import viewsets, views
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from common.utils import model_to_json
|
||||
from common.utils import data_to_json
|
||||
from .. import utils
|
||||
from common.const.http import GET
|
||||
from common.utils import get_logger, get_object_or_none
|
||||
|
@ -62,7 +62,9 @@ class SessionViewSet(OrgBulkModelViewSet):
|
|||
os.chdir(dir_path)
|
||||
|
||||
with open(meta_filename, 'wt') as f:
|
||||
f.write(model_to_json(session))
|
||||
serializer = serializers.SessionDisplaySerializer(session)
|
||||
data = data_to_json(serializer.data)
|
||||
f.write(data)
|
||||
|
||||
with tarfile.open(offline_filename, 'w') as f:
|
||||
f.add(replay_filename)
|
||||
|
|
Loading…
Reference in New Issue