Merge branches 'dev' and 'dev' of github.com:jumpserver/jumpserver into dev

This commit is contained in:
ibuler
2026-07-08 15:34:47 +08:00
32 changed files with 577 additions and 193 deletions

View File

@@ -70,4 +70,6 @@ jobs:
push: true
context: utils/ansible_executor/context
file: utils/ansible_executor/context/Containerfile
tags: jumpserver/ansible-executor:${{ env.IMAGE_TAG }}
tags: |
jumpserver/ansible-executor:${{ env.IMAGE_TAG }}
jumpserver/ansible-executor:latest

View File

@@ -36,7 +36,8 @@ ARG TOOLS=" \
postgresql-client \
openssh-client \
sshpass \
bubblewrap"
bubblewrap \
docker-cli"
ARG APT_MIRROR=http://deb.debian.org

View File

@@ -2,6 +2,7 @@
gather_facts: no
vars:
ansible_connection: local
ansible_python_interpreter: "{{ local_python_interpreter }}"
ansible_become: false
tasks:

View File

@@ -33,7 +33,9 @@
name: '{{ jms_asset.spec_info.db_name }}'
encryption: "{{ jms_asset.encryption | default(None) }}"
tds_version: "{{ jms_asset.tds_version | default(None) }}"
script: "SELECT 1 from sys.sql_logins WHERE name='{{ account.username }}';"
script: "SELECT 1 FROM sys.sql_logins WHERE name=%(username)s;"
params:
username: "{{ account.username }}"
when: db_info is succeeded
register: user_exist
@@ -46,7 +48,35 @@
name: '{{ jms_asset.spec_info.db_name }}'
encryption: "{{ jms_asset.encryption | default(None) }}"
tds_version: "{{ jms_asset.tds_version | default(None) }}"
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version"
script: |
DECLARE @login_name nvarchar(256) = %(username)s;
DECLARE @password nvarchar(4000) = %(password)s;
DECLARE @db_name nvarchar(256) = %(db_name)s;
DECLARE @sql nvarchar(max);
IF @login_name IS NULL OR LEN(@login_name) = 0 OR LEN(@login_name) > 128
BEGIN
RAISERROR('Invalid login name', 16, 1);
RETURN;
END;
IF @db_name IS NULL OR LEN(@db_name) = 0 OR LEN(@db_name) > 128 OR DB_ID(@db_name) IS NULL
BEGIN
RAISERROR('Invalid default database', 16, 1);
RETURN;
END;
-- 标识符使用 QUOTENAME 防止名称逃逸,密码在拼接前转义单引号。
SET @sql = N'ALTER LOGIN ' + QUOTENAME(@login_name)
+ N' WITH PASSWORD = N''' + REPLACE(@password, N'''', N'''''') + N''', DEFAULT_DATABASE = '
+ QUOTENAME(@db_name) + N';';
EXEC sys.sp_executesql @sql;
SELECT @@VERSION AS version;
params:
username: "{{ account.username }}"
password: "{{ account.secret }}"
db_name: "{{ jms_asset.spec_info.db_name }}"
ignore_errors: true
when: user_exist.query_results[0] | length != 0
@@ -59,7 +89,43 @@
name: '{{ jms_asset.spec_info.db_name }}'
encryption: "{{ jms_asset.encryption | default(None) }}"
tds_version: "{{ jms_asset.tds_version | default(None) }}"
script: "CREATE LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; CREATE USER {{ account.username }} FOR LOGIN {{ account.username }}; select @@version"
script: |
DECLARE @login_name nvarchar(256) = %(username)s;
DECLARE @password nvarchar(4000) = %(password)s;
DECLARE @db_name nvarchar(256) = %(db_name)s;
DECLARE @sql nvarchar(max);
IF @login_name IS NULL OR LEN(@login_name) = 0 OR LEN(@login_name) > 128
BEGIN
RAISERROR('Invalid login name', 16, 1);
RETURN;
END;
IF @db_name IS NULL OR LEN(@db_name) = 0 OR LEN(@db_name) > 128 OR DB_ID(@db_name) IS NULL
BEGIN
RAISERROR('Invalid default database', 16, 1);
RETURN;
END;
-- 标识符使用 QUOTENAME 防止名称逃逸,密码在拼接前转义单引号。
SET @sql = N'CREATE LOGIN ' + QUOTENAME(@login_name)
+ N' WITH PASSWORD = N''' + REPLACE(@password, N'''', N'''''') + N''', DEFAULT_DATABASE = '
+ QUOTENAME(@db_name) + N';';
EXEC sys.sp_executesql @sql;
IF USER_ID(@login_name) IS NULL
BEGIN
SET @sql = N'CREATE USER ' + QUOTENAME(@login_name)
+ N' FOR LOGIN ' + QUOTENAME(@login_name) + N';';
EXEC sys.sp_executesql @sql;
END;
SELECT @@VERSION AS version;
params:
username: "{{ account.username }}"
password: "{{ account.secret }}"
db_name: "{{ jms_asset.spec_info.db_name }}"
ignore_errors: true
when: user_exist.query_results[0] | length == 0

View File

@@ -2,6 +2,7 @@
gather_facts: no
vars:
ansible_connection: local
ansible_python_interpreter: "{{ local_python_interpreter }}"
ansible_become: false
tasks:
@@ -67,4 +68,4 @@
recv_timeout: "{{ params.recv_timeout | default(30) }}"
delegate_to: localhost
connection: local
when: check_conn_after_change
when: check_conn_after_change

View File

@@ -33,7 +33,9 @@
name: '{{ jms_asset.spec_info.db_name }}'
encryption: "{{ jms_asset.encryption | default(None) }}"
tds_version: "{{ jms_asset.tds_version | default(None) }}"
script: "SELECT 1 from sys.sql_logins WHERE name='{{ account.username }}';"
script: "SELECT 1 FROM sys.sql_logins WHERE name=%(username)s;"
params:
username: "{{ account.username }}"
when: db_info is succeeded
register: user_exist
@@ -46,7 +48,35 @@
name: '{{ jms_asset.spec_info.db_name }}'
encryption: "{{ jms_asset.encryption | default(None) }}"
tds_version: "{{ jms_asset.tds_version | default(None) }}"
script: "ALTER LOGIN {{ account.username }} WITH PASSWORD = '{{ account.secret }}', DEFAULT_DATABASE = {{ jms_asset.spec_info.db_name }}; select @@version"
script: |
DECLARE @login_name nvarchar(256) = %(username)s;
DECLARE @password nvarchar(4000) = %(password)s;
DECLARE @db_name nvarchar(256) = %(db_name)s;
DECLARE @sql nvarchar(max);
IF @login_name IS NULL OR LEN(@login_name) = 0 OR LEN(@login_name) > 128
BEGIN
RAISERROR('Invalid login name', 16, 1);
RETURN;
END;
IF @db_name IS NULL OR LEN(@db_name) = 0 OR LEN(@db_name) > 128 OR DB_ID(@db_name) IS NULL
BEGIN
RAISERROR('Invalid default database', 16, 1);
RETURN;
END;
-- 标识符使用 QUOTENAME 防止名称逃逸,密码在拼接前转义单引号。
SET @sql = N'ALTER LOGIN ' + QUOTENAME(@login_name)
+ N' WITH PASSWORD = N''' + REPLACE(@password, N'''', N'''''') + N''', DEFAULT_DATABASE = '
+ QUOTENAME(@db_name) + N';';
EXEC sys.sp_executesql @sql;
SELECT @@VERSION AS version;
params:
username: "{{ account.username }}"
password: "{{ account.secret }}"
db_name: "{{ jms_asset.spec_info.db_name }}"
ignore_errors: true
when: user_exist.query_results[0] | length != 0
register: change_info
@@ -60,7 +90,34 @@
name: '{{ jms_asset.spec_info.db_name }}'
encryption: "{{ jms_asset.encryption | default(None) }}"
tds_version: "{{ jms_asset.tds_version | default(None) }}"
script: "CREATE LOGIN [{{ account.username }}] WITH PASSWORD = '{{ account.secret }}'; CREATE USER [{{ account.username }}] FOR LOGIN [{{ account.username }}]; select @@version"
script: |
DECLARE @login_name nvarchar(256) = %(username)s;
DECLARE @password nvarchar(4000) = %(password)s;
DECLARE @sql nvarchar(max);
IF @login_name IS NULL OR LEN(@login_name) = 0 OR LEN(@login_name) > 128
BEGIN
RAISERROR('Invalid login name', 16, 1);
RETURN;
END;
-- 标识符使用 QUOTENAME 防止名称逃逸,密码在拼接前转义单引号。
SET @sql = N'CREATE LOGIN ' + QUOTENAME(@login_name)
+ N' WITH PASSWORD = N''' + REPLACE(@password, N'''', N'''''') + N''';';
EXEC sys.sp_executesql @sql;
IF USER_ID(@login_name) IS NULL
BEGIN
SET @sql = N'CREATE USER ' + QUOTENAME(@login_name)
+ N' FOR LOGIN ' + QUOTENAME(@login_name) + N';';
EXEC sys.sp_executesql @sql;
END;
SELECT @@VERSION AS version;
params:
username: "{{ account.username }}"
password: "{{ account.secret }}"
ignore_errors: true
when: user_exist.query_results[0] | length == 0
register: change_info

View File

@@ -13,5 +13,20 @@
name: "{{ jms_asset.spec_info.db_name }}"
encryption: "{{ jms_asset.encryption | default(None) }}"
tds_version: "{{ jms_asset.tds_version | default(None) }}"
script: "DROP LOGIN {{ account.username }}; select @@version"
script: |
DECLARE @login_name nvarchar(256) = %(username)s;
DECLARE @sql nvarchar(max);
IF @login_name IS NULL OR LEN(@login_name) = 0 OR LEN(@login_name) > 128
BEGIN
RAISERROR('Invalid login name', 16, 1);
RETURN;
END;
-- 标识符使用 QUOTENAME 防止 login name 逃逸。
SET @sql = N'DROP LOGIN ' + QUOTENAME(@login_name) + N';';
EXEC sys.sp_executesql @sql;
SELECT @@VERSION AS version;
params:
username: "{{ account.username }}"

View File

@@ -3,6 +3,7 @@
vars:
ansible_connection: local
ansible_shell_type: sh
ansible_python_interpreter: "{{ local_python_interpreter }}"
ansible_become: false
tasks:

View File

@@ -3,6 +3,7 @@
vars:
ansible_connection: local
ansible_shell_type: sh
ansible_python_interpreter: "{{ local_python_interpreter }}"
ansible_become: false
ansible_timeout: 30
tasks:
@@ -22,4 +23,3 @@
old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}"
gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}"
recv_timeout: "{{ params.recv_timeout | default(30) }}"

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.13 on 2026-07-06 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audits', '0007_auto_20250610_1704'),
]
operations = [
migrations.AddField(
model_name='userloginlog',
name='reason_code',
field=models.CharField(blank=True, default='', max_length=64, verbose_name='Reason code'),
),
migrations.AddField(
model_name='userloginlog',
name='reason_params',
field=models.JSONField(blank=True, default=dict, verbose_name='Reason params'),
),
]

View File

@@ -12,7 +12,7 @@ from django.utils.translation import gettext, gettext_lazy as _
from common.db.encoder import ModelJSONFieldEncoder
from common.sessions.cache import user_session_manager
from common.utils import lazyproperty, i18n_trans
from common.utils import lazyproperty, i18n_trans, get_ip_city
from ops.models import JobExecution
from orgs.mixins.models import OrgModelMixin, Organization
from orgs.utils import current_org
@@ -38,6 +38,13 @@ __all__ = [
]
def _get_city_display(ip, city='') -> str:
try:
return get_ip_city(ip) or gettext(city or '')
except Exception:
return gettext(city or '')
class JobLog(JobExecution):
@property
def creator_name(self) -> str:
@@ -218,6 +225,12 @@ class UserLoginLog(models.Model):
reason = models.CharField(
default="", max_length=128, blank=True, verbose_name=_("Reason")
)
reason_code = models.CharField(
default='', max_length=64, blank=True, verbose_name=_("Reason code")
)
reason_params = models.JSONField(
default=dict, blank=True, verbose_name=_("Reason params")
)
status = models.BooleanField(
default=LoginStatusChoices.success,
choices=LoginStatusChoices.choices,
@@ -235,6 +248,10 @@ class UserLoginLog(models.Model):
def backend_display(self) -> str:
return gettext(self.backend)
@lazyproperty
def city_display(self) -> str:
return _get_city_display(self.ip, self.city)
@classmethod
def get_login_logs(cls, date_from=None, date_to=None, user=None, keyword=None):
login_logs = cls.objects.all()
@@ -259,7 +276,23 @@ class UserLoginLog(models.Model):
@property
def reason_display(self) -> str:
from authentication.errors import reason_choices, old_reason_choices
from authentication.errors import (
reason_choices, reason_template_choices, old_reason_choices
)
template = reason_template_choices.get(self.reason_code)
if template:
reason_params = self.reason_params
if not isinstance(reason_params, dict):
reason_params = {}
try:
return template.format(**reason_params)
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
pass
reason = reason_choices.get(self.reason_code)
if reason:
return reason
reason = reason_choices.get(self.reason)
if reason:
@@ -299,6 +332,10 @@ class UserSession(models.Model):
def __str__(self):
return '%s(%s)' % (self.user, self.ip)
@lazyproperty
def city_display(self) -> str:
return _get_city_display(self.ip, self.city)
@property
def backend_display(self) -> str:
return gettext(self.backend)

View File

@@ -75,6 +75,7 @@ class UserLoginLogSerializer(serializers.ModelSerializer):
mfa = LabeledChoiceField(choices=MFAChoices.choices, label=_("MFA"))
type = LabeledChoiceField(choices=LoginTypeChoices.choices, label=_("Type"))
status = LabeledChoiceField(choices=LoginStatusChoices.choices, label=_("Status"))
city = serializers.ReadOnlyField(source='city_display', label=_("Login city"))
class Meta:
model = models.UserLoginLog
@@ -223,6 +224,7 @@ class UserSessionSerializer(serializers.ModelSerializer):
user = ObjectRelatedField(required=False, queryset=User.objects, label=_('User'))
date_expired = serializers.DateTimeField(format="%Y/%m/%d %H:%M:%S", label=_('Date expired'))
is_current_user_session = serializers.SerializerMethodField()
city = serializers.ReadOnlyField(source='city_display', label=_("Login city"))
class Meta:
model = models.UserSession

View File

@@ -144,5 +144,13 @@ def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
def on_user_auth_failed(sender, username, request, reason='', **kwargs):
logger.debug('User login failed: {}'.format(username))
data = generate_data(username, request)
data.update({'reason': reason[:128], 'status': False})
reason_params = kwargs.get('reason_params') or {}
if not isinstance(reason_params, dict):
reason_params = {}
data.update({
'reason': reason[:128],
'reason_code': kwargs.get('reason_code') or '',
'reason_params': reason_params,
'status': False,
})
write_login_log(**data)

View File

@@ -74,7 +74,10 @@ class RedirectAuthBackend(JMSBaseAuthBackend):
def send_backend_auth_failed_signal(self, request, username=None, reason=None):
default_reason = reason_choices.get(reason_user_invalid, reason)
reason_code = reason_user_invalid if reason is None else ''
if reason in reason_choices:
reason_code = reason
backend_auth_failed.send(
sender=self.__class__, username=username, request=request,
reason=default_reason, backend=self.backend
reason=default_reason, backend=self.backend, reason_code=reason_code
)

View File

@@ -13,6 +13,11 @@ reason_user_expired = 'user_expired'
reason_backend_not_match = 'backend_not_match'
reason_acl_not_allow = 'acl_not_allow'
only_local_users_are_allowed = 'only_local_users_are_allowed'
reason_invalid_login = 'invalid_login'
reason_block_user_login = 'block_user_login'
reason_block_ip_login = 'block_ip_login'
reason_mfa_error = 'mfa_error'
reason_block_mfa = 'block_mfa'
reason_choices = {
reason_password_failed: _('Username/password check failed'),
@@ -45,23 +50,30 @@ invalid_login_msg = _(
)
block_user_login_msg = _(
"The account has been locked "
"(please contact admin to unlock it or try again after {} minutes)"
"(please contact admin to unlock it or try again after {block_time} minutes)"
)
block_ip_login_msg = _(
"The address has been locked "
"(please contact admin to unlock it or try again after {} minutes)"
"(please contact admin to unlock it or try again after {block_time} minutes)"
)
block_mfa_msg = _(
"The account has been locked "
"(please contact admin to unlock it or try again after {} minutes)"
"(please contact admin to unlock it or try again after {block_time} minutes)"
)
mfa_error_msg = _(
"{error}, "
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
reason_template_choices = {
reason_invalid_login: invalid_login_msg,
reason_block_user_login: block_user_login_msg,
reason_block_ip_login: block_ip_login_msg,
reason_mfa_error: mfa_error_msg,
reason_block_mfa: block_mfa_msg,
}
mfa_required_msg = _("MFA required")
mfa_unset_msg = _("MFA not set, please set it first")
login_confirm_required_msg = _("Login confirm required")
login_confirm_wait_msg = _("Wait login confirm ticket for accept")
login_confirm_error_msg = _("Login confirm ticket was {}")
login_confirm_error_msg = _("Login confirm ticket was {status}")

View File

@@ -18,7 +18,9 @@ class AuthFailedNeedLogMixin:
super().__init__(*args, **kwargs)
post_auth_failed.send(
sender=self.__class__, username=self.username,
request=self.request, reason=self.msg
request=self.request, reason=self.msg,
reason_code=getattr(self, 'reason_code', self.error),
reason_params=getattr(self, 'reason_params', {}),
)
@@ -61,7 +63,9 @@ class BlockGlobalIpLoginError(AuthFailedError):
def __init__(self, username, ip, **kwargs):
if not self.msg:
self.msg = const.block_ip_login_msg.format(settings.SECURITY_LOGIN_IP_LIMIT_TIME)
self.reason_code = const.reason_block_ip_login
self.reason_params = {'block_time': settings.SECURITY_LOGIN_IP_LIMIT_TIME}
self.msg = const.block_ip_login_msg.format(**self.reason_params)
LoginIpBlockUtil(ip).set_block_if_need()
super().__init__(username=username, ip=ip, **kwargs)
@@ -75,14 +79,23 @@ class CredentialError(
times_remainder = util.get_remainder_times()
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_remainder < 1:
self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
self.reason_code = const.reason_block_user_login
self.reason_params = {'block_time': block_time}
self.msg = const.block_user_login_msg.format(**self.reason_params)
else:
invalid_login_params = {
'times_try': times_remainder, 'block_time': block_time,
}
default_msg = const.invalid_login_msg.format(
times_try=times_remainder, block_time=block_time
**invalid_login_params
)
if error == const.reason_password_failed:
self.reason_code = const.reason_invalid_login
self.reason_params = invalid_login_params
self.msg = default_msg
else:
self.reason_code = error
self.reason_params = {}
self.msg = const.reason_choices.get(error, default_msg)
# 先处理 msg 在 super记录日志时原因才准确
super().__init__(error=error, username=username, ip=ip, request=request)
@@ -98,11 +111,17 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_remainder:
self.reason_code = const.reason_mfa_error
self.reason_params = {
'error': str(error), 'times_try': times_remainder, 'block_time': block_time,
}
self.msg = const.mfa_error_msg.format(
error=error, times_try=times_remainder, block_time=block_time
**self.reason_params
)
else:
self.msg = const.block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
self.reason_code = const.reason_block_mfa
self.reason_params = {'block_time': block_time}
self.msg = const.block_mfa_msg.format(**self.reason_params)
super().__init__(username=username, request=request)
@@ -110,7 +129,9 @@ class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
error = 'block_mfa'
def __init__(self, username, request, ip):
self.msg = const.block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
self.reason_code = const.reason_block_mfa
self.reason_params = {'block_time': settings.SECURITY_LOGIN_LIMIT_TIME}
self.msg = const.block_mfa_msg.format(**self.reason_params)
super().__init__(username=username, request=request, ip=ip)
@@ -118,7 +139,9 @@ class BlockLoginError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFail
error = 'block_login'
def __init__(self, username, ip, request):
self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
self.reason_code = const.reason_block_user_login
self.reason_params = {'block_time': settings.SECURITY_LOGIN_LIMIT_TIME}
self.msg = const.block_user_login_msg.format(**self.reason_params)
super().__init__(username=username, ip=ip, request=request)

View File

@@ -71,7 +71,7 @@ class LoginConfirmOtherError(LoginConfirmBaseError):
def __init__(self, ticket_id, status, username):
self.username = username
msg = const.login_confirm_error_msg.format(status)
msg = const.login_confirm_error_msg.format(status=status)
super().__init__(ticket_id=ticket_id, msg=msg)
def as_data(self):

View File

@@ -46,5 +46,8 @@ def on_user_auth_login_success(sender, user, request, **kwargs):
@receiver(backend_auth_failed)
def on_user_login_failed(sender, username, request, reason, backend, **kwargs):
request.session['auth_backend'] = backend
post_auth_failed.send(sender, username=username, request=request, reason=reason)
post_auth_failed.send(
sender, username=username, request=request, reason=reason,
reason_code=kwargs.get('reason_code') or '',
reason_params=kwargs.get('reason_params') or {},
)

View File

@@ -39,7 +39,11 @@
let token;
function createFaceCaptureToken() {
const csrf = getCookie('jms_csrftoken');
let prefix = getCookie('SESSION_COOKIE_NAME_PREFIX');
if (!prefix || [`""`, `''`].includes(prefix)) {
prefix = '';
}
const csrf = getCookie(`${prefix}csrftoken`);
$.ajax({
url: apiUrl,
method: 'POST',

View File

@@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.generic.base import TemplateView
from common.utils import bulk_get, FlashMessageUtil
from common.utils import bulk_get, FlashMessageUtil, safe_next_url
@method_decorator(never_cache, name='dispatch')
@@ -23,6 +23,8 @@ class FlashMessageMsgView(TemplateView):
items = ('title', 'message', 'error', 'redirect_url', 'confirm_button', 'cancel_url')
title, msg, error, redirect_url, confirm_btn, cancel_url = bulk_get(message_data, items)
redirect_url = safe_next_url(redirect_url, request=request)
cancel_url = safe_next_url(cancel_url, request=request)
interval = message_data.get('interval', 3)
auto_redirect = message_data.get('auto_redirect', True)

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: JumpServer 0.3.3\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-01 16:45+0800\n"
"POT-Creation-Date: 2026-07-06 15:45+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"
@@ -347,7 +347,7 @@ msgstr "SFTP"
#: accounts/const/automation.py:116
#: accounts/serializers/automations/change_secret.py:172 audits/const.py:65
#: audits/models.py:65 audits/signal_handlers/activity_log.py:34
#: common/const/choices.py:66 ops/const.py:75 ops/serializers/celery.py:48
#: common/const/choices.py:66 ops/const.py:75 ops/serializers/celery.py:42
#: terminal/const.py:80 terminal/models/session/sharing.py:128
#: tickets/views/approve.py:128
msgid "Success"
@@ -467,7 +467,7 @@ msgstr "Vault 操作失败,请重试,或者检查 Vault 上的账号信息
#: accounts/templates/accounts/push_account_report.html:118
#: acls/notifications.py:70 acls/serializers/base.py:110
#: assets/models/asset/common.py:102 assets/models/asset/common.py:427
#: assets/models/cmd_filter.py:36 audits/models.py:59 audits/models.py:336
#: assets/models/cmd_filter.py:36 audits/models.py:59 audits/models.py:358
#: audits/reporting.py:109 audits/serializers.py:256
#: authentication/models/connection_token.py:42
#: perms/models/asset_permission.py:69 terminal/backends/command/models.py:17
@@ -535,7 +535,7 @@ msgstr "改密状态"
#: accounts/serializers/automations/change_secret.py:150
#: acls/serializers/base.py:111
#: acls/templates/acls/asset_login_reminder.html:11
#: assets/serializers/gateway.py:33 audits/models.py:60 audits/models.py:337
#: assets/serializers/gateway.py:33 audits/models.py:60 audits/models.py:359
#: audits/reporting.py:114 audits/serializers.py:257
#: authentication/api/connection_token.py:643 ops/models/base.py:18
#: perms/models/asset_permission.py:75 settings/serializers/msg.py:33
@@ -783,7 +783,7 @@ msgstr "结束日期"
#: accounts/models/automations/gather_account.py:26
#: accounts/serializers/automations/check_account.py:39
#: assets/models/automations/base.py:137
#: assets/serializers/automations/base.py:47 audits/models.py:224
#: assets/serializers/automations/base.py:47 audits/models.py:230
#: audits/reporting.py:607 audits/serializers.py:77 ops/models/base.py:49
#: ops/models/job.py:233 terminal/models/applet/applet.py:391
#: terminal/models/applet/host.py:140 terminal/models/component/status.py:30
@@ -863,7 +863,7 @@ msgid "Authorized keys changed"
msgstr "密钥变更"
#: accounts/models/automations/check_account.py:49
#: authentication/errors/const.py:23
#: authentication/errors/const.py:28
msgid "Password expired"
msgstr "密码已过期"
@@ -1310,7 +1310,7 @@ msgstr "ID"
#: acls/templates/acls/asset_login_reminder.html:8
#: acls/templates/acls/user_login_reminder.html:8
#: assets/models/cmd_filter.py:24 assets/models/label.py:16 audits/models.py:55
#: audits/models.py:97 audits/models.py:179 audits/models.py:296
#: audits/models.py:97 audits/models.py:179 audits/models.py:318
#: audits/reporting.py:104 audits/reporting.py:317 audits/reporting.py:414
#: audits/serializers.py:223 authentication/models/connection_token.py:38
#: authentication/models/ssh_key.py:22 authentication/models/sso_token.py:16
@@ -2026,7 +2026,7 @@ msgstr "用户登录提醒"
#: acls/notifications.py:17 acls/notifications.py:67
#: acls/templates/acls/user_login_reminder.html:10 audits/models.py:210
#: audits/models.py:290 authentication/notifications.py:15
#: audits/models.py:312 authentication/notifications.py:15
#: authentication/templates/authentication/_msg_different_city.html:11
#: tickets/models/ticket/login_confirm.py:13
msgid "Login city"
@@ -2041,7 +2041,7 @@ msgid "Recipient username"
msgstr "收件人用户名"
#: acls/notifications.py:22 acls/templates/acls/user_login_reminder.html:12
#: audits/models.py:213 audits/models.py:291 audits/serializers.py:91
#: audits/models.py:213 audits/models.py:313 audits/serializers.py:91
msgid "User agent"
msgstr "用户代理"
@@ -3611,7 +3611,7 @@ msgid "Job audit log"
msgstr "作业审计日志"
#: audits/models.py:57 audits/models.py:107 audits/models.py:182
#: audits/models.py:333 audits/reporting.py:119 audits/reporting.py:327
#: audits/models.py:355 audits/reporting.py:119 audits/reporting.py:327
#: audits/reporting.py:424 authentication/models/connection_token.py:63
#: terminal/models/session/session.py:36 terminal/models/session/sharing.py:120
#: terminal/notifications.py:171 terminal/notifications.py:238
@@ -3654,7 +3654,7 @@ msgid "Resource"
msgstr "资源"
#: audits/models.py:108 audits/models.py:154 audits/models.py:184
#: audits/models.py:338 audits/serializers.py:258
#: audits/models.py:360 audits/serializers.py:258
#: terminal/serializers/command.py:75
msgid "Datetime"
msgstr "日期"
@@ -3679,11 +3679,11 @@ msgstr "修改者"
msgid "Password change log"
msgstr "改密日志"
#: audits/models.py:206 audits/models.py:292
#: audits/models.py:206 audits/models.py:314
msgid "Login type"
msgstr "登录方式"
#: audits/models.py:208 audits/models.py:288
#: audits/models.py:208 audits/models.py:310
#: tickets/models/ticket/login_confirm.py:12
msgid "Login IP"
msgstr "登录 IP"
@@ -3701,41 +3701,49 @@ msgstr "MFA"
msgid "Reason"
msgstr "原因"
#: audits/models.py:226 authentication/notifications.py:19
#: audits/models.py:222
msgid "Reason code"
msgstr "原因"
#: audits/models.py:225
msgid "Reason params"
msgstr "原因参数"
#: audits/models.py:232 authentication/notifications.py:19
#: authentication/templates/authentication/_msg_different_city.html:10
#: tickets/models/ticket/login_confirm.py:14
msgid "Login Date"
msgstr "登录日期"
#: audits/models.py:228 audits/models.py:293 audits/reporting.py:226
#: audits/models.py:234 audits/models.py:315 audits/reporting.py:226
msgid "Auth backend"
msgstr "认证令牌"
#: audits/models.py:281
#: audits/models.py:303
msgid "User login log"
msgstr "用户登录日志"
#: audits/models.py:289
#: audits/models.py:311
msgid "Session key"
msgstr "会话标识"
#: audits/models.py:294
#: audits/models.py:316
msgid "Login date"
msgstr "登录日期"
#: audits/models.py:325
#: audits/models.py:347
msgid "User session"
msgstr "用户会话"
#: audits/models.py:327
#: audits/models.py:349
msgid "Offline user session"
msgstr "下线用户会话"
#: audits/models.py:334
#: audits/models.py:356
msgid "Application"
msgstr "应用"
#: audits/models.py:335
#: audits/models.py:357
msgid "Application ID"
msgstr "应用 ID"
@@ -4052,7 +4060,7 @@ msgstr "耗时分布"
msgid "Duration range"
msgstr "耗时范围"
#: audits/serializers.py:39 ops/serializers/celery.py:33
#: audits/serializers.py:39 ops/serializers/celery.py:27
msgid "Execution cycle"
msgstr "周期执行"
@@ -4430,55 +4438,55 @@ msgstr "Radius"
msgid "Custom"
msgstr "自定义"
#: authentication/errors/const.py:18
#: authentication/errors/const.py:23
msgid "Username/password check failed"
msgstr "用户名/密码 校验失败"
#: authentication/errors/const.py:19
#: authentication/errors/const.py:24
msgid "Password decrypt failed"
msgstr "密码解密失败"
#: authentication/errors/const.py:20
#: authentication/errors/const.py:25
msgid "MFA failed"
msgstr "MFA 校验失败"
#: authentication/errors/const.py:21
#: authentication/errors/const.py:26
msgid "MFA unset"
msgstr "MFA 没有设定"
#: authentication/errors/const.py:22
#: authentication/errors/const.py:27
msgid "Username does not exist"
msgstr "用户名不存在"
#: authentication/errors/const.py:24
#: authentication/errors/const.py:29
msgid "Disabled or expired"
msgstr "禁用或失效"
#: authentication/errors/const.py:25
#: authentication/errors/const.py:30
msgid "This account is inactive."
msgstr "此账号已禁用"
#: authentication/errors/const.py:26
#: authentication/errors/const.py:31
msgid "This account is expired"
msgstr "此账号已过期"
#: authentication/errors/const.py:27
#: authentication/errors/const.py:32
msgid "Auth backend not match"
msgstr "没有匹配到认证后端"
#: authentication/errors/const.py:28
#: authentication/errors/const.py:33
msgid "ACL is not allowed"
msgstr "登录访问控制不被允许"
#: authentication/errors/const.py:29
#: authentication/errors/const.py:34
msgid "Only local users are allowed"
msgstr "仅允许本地用户"
#: authentication/errors/const.py:39
#: authentication/errors/const.py:44
msgid "No session found, check your cookie"
msgstr "会话已变更,刷新页面"
#: authentication/errors/const.py:41
#: authentication/errors/const.py:46
#, python-brace-format
msgid ""
"The username or password you entered is incorrect, please enter it again. "
@@ -4488,21 +4496,19 @@ msgstr ""
"您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次 (账号将"
"被临时 锁定 {block_time} 分钟)"
#: authentication/errors/const.py:47 authentication/errors/const.py:55
#, python-brace-format
#: authentication/errors/const.py:52 authentication/errors/const.py:60
msgid ""
"The account has been locked (please contact admin to unlock it or try again "
"after {} minutes)"
msgstr "账号已被锁定 (请联系管理员解锁或{}分钟后重试)"
"after {block_time} minutes)"
msgstr "账号已被锁定 (请联系管理员解锁或{block_time}分钟后重试)"
#: authentication/errors/const.py:51
#, python-brace-format
#: authentication/errors/const.py:56
msgid ""
"The address has been locked (please contact admin to unlock it or try again "
"after {} minutes)"
msgstr "IP 已被锁定 (请联系管理员解锁或 {} 分钟后重试)"
"after {block_time} minutes)"
msgstr "IP 已被锁定 (请联系管理员解锁或 {block_time} 分钟后重试)"
#: authentication/errors/const.py:59
#: authentication/errors/const.py:64
#, python-brace-format
msgid ""
"{error}, You can also try {times_try} times (The account will be temporarily "
@@ -4510,40 +4516,39 @@ msgid ""
msgstr ""
"{error} 您还可以尝试 {times_try} 次 (账号将被临时锁定 {block_time} 分钟)"
#: authentication/errors/const.py:63
#: authentication/errors/const.py:75
msgid "MFA required"
msgstr "需要 MFA 认证"
#: authentication/errors/const.py:64
#: authentication/errors/const.py:76
msgid "MFA not set, please set it first"
msgstr "MFA 没有设置,请先完成设置"
#: authentication/errors/const.py:65
#: authentication/errors/const.py:77
msgid "Login confirm required"
msgstr "需要登录复核"
#: authentication/errors/const.py:66
#: authentication/errors/const.py:78
msgid "Wait login confirm ticket for accept"
msgstr "等待登录复核处理"
#: authentication/errors/const.py:67
#, python-brace-format
msgid "Login confirm ticket was {}"
msgstr "登录复核: {}"
#: authentication/errors/const.py:79
msgid "Login confirm ticket was {status}"
msgstr "登录复核: {status}"
#: authentication/errors/failed.py:149
#: authentication/errors/failed.py:172
msgid "Current login is prohibited by ACL rules"
msgstr "当前登录ACL规则禁止登录"
#: authentication/errors/failed.py:154
#: authentication/errors/failed.py:177
msgid "Please enter MFA code"
msgstr "请输入 MFA 验证码"
#: authentication/errors/failed.py:159
#: authentication/errors/failed.py:182
msgid "Please enter SMS code"
msgstr "请输入短信验证码"
#: authentication/errors/failed.py:164 users/exceptions.py:14
#: authentication/errors/failed.py:187 users/exceptions.py:14
msgid "Phone not set"
msgstr "手机号没有设置"
@@ -4708,7 +4713,7 @@ msgstr "设置手机号码启用"
msgid "Clear phone number to disable"
msgstr "清空手机号码禁用"
#: authentication/middleware.py:95 settings/utils/ldap.py:767
#: authentication/middleware.py:95 settings/utils/ldap.py:771
#, python-brace-format
msgid "Authentication failed (before login check failed): {}"
msgstr "认证失败 (登录前检查失败): {}"
@@ -6128,16 +6133,12 @@ msgstr "Ansible 已禁用"
msgid "Skip hosts below:"
msgstr "跳过以下主机: "
#: ops/api/celery.py:66 ops/api/celery.py:81
msgid "Waiting task start"
msgstr "等待任务开始"
#: ops/api/celery.py:269
#: ops/api/celery.py:204
#, python-brace-format
msgid "Task {} not found"
msgstr "任务 {} 不存在"
#: ops/api/celery.py:276
#: ops/api/celery.py:211
#, python-brace-format
msgid "Task {} args or kwargs error"
msgstr "任务 {} 执行参数错误"
@@ -6529,7 +6530,7 @@ msgstr "内存使用率超过 {max_threshold}%: => {value}"
msgid "CPU load more than {max_threshold}: => {value}"
msgstr "CPU 使用率超过 {max_threshold}: => {value}"
#: ops/serializers/celery.py:35
#: ops/serializers/celery.py:29
msgid "Next execution time"
msgstr "下次执行时间"
@@ -8993,119 +8994,119 @@ msgstr "已同步用户"
msgid "No user synchronization required"
msgstr "没有用户需要同步"
#: settings/utils/ldap.py:580
#: settings/utils/ldap.py:583
msgid "ldap:// or ldaps:// protocol is used."
msgstr "使用 ldap:// 或 ldaps:// 协议"
#: settings/utils/ldap.py:594
#: settings/utils/ldap.py:597
#, python-brace-format
msgid "Host or port is disconnected: {}"
msgstr "主机或端口不可连接: {}"
#: settings/utils/ldap.py:596
#: settings/utils/ldap.py:599
#, python-brace-format
msgid "The port is not the port of the LDAP service: {}"
msgstr "端口不是LDAP服务端口: {}"
#: settings/utils/ldap.py:598
#: settings/utils/ldap.py:601
#, python-brace-format
msgid "Please add certificate: {}"
msgstr "请添加证书: {}"
#: settings/utils/ldap.py:602 settings/utils/ldap.py:629
#: settings/utils/ldap.py:659 settings/utils/ldap.py:687
#: settings/utils/ldap.py:605 settings/utils/ldap.py:632
#: settings/utils/ldap.py:662 settings/utils/ldap.py:690
#, python-brace-format
msgid "Unknown error: {}"
msgstr "未知错误: {}"
#: settings/utils/ldap.py:616
#: settings/utils/ldap.py:619
msgid "Bind DN or Password incorrect"
msgstr "绑定DN或密码错误"
#: settings/utils/ldap.py:623
#: settings/utils/ldap.py:626
#, python-brace-format
msgid "Please enter Bind DN: {}"
msgstr "请输入绑定DN: {}"
#: settings/utils/ldap.py:625
#: settings/utils/ldap.py:628
#, python-brace-format
msgid "Please enter Password: {}"
msgstr "请输入密码: {}"
#: settings/utils/ldap.py:627
#: settings/utils/ldap.py:630
#, python-brace-format
msgid "Please enter correct Bind DN and Password: {}"
msgstr "请输入正确的绑定DN和密码: {}"
#: settings/utils/ldap.py:645
#: settings/utils/ldap.py:648
#, python-brace-format
msgid "Invalid User OU or User search filter: {}"
msgstr "不合法的用户OU或用户过滤器: {}"
#: settings/utils/ldap.py:676
#: settings/utils/ldap.py:679
#, python-brace-format
msgid "LDAP User attr map not include: {}"
msgstr "LDAP属性映射没有包含: {}"
#: settings/utils/ldap.py:683
#: settings/utils/ldap.py:686
msgid "LDAP User attr map is not dict"
msgstr "LDAP属性映射不合法"
#: settings/utils/ldap.py:702
#: settings/utils/ldap.py:705
msgid "LDAP authentication is not enabled"
msgstr "LDAP认证没有启用"
#: settings/utils/ldap.py:720
#: settings/utils/ldap.py:724
#, python-brace-format
msgid "Error (Invalid LDAP server): {}"
msgstr "错误 (不合法的LDAP服务器地址): {}"
#: settings/utils/ldap.py:722
#: settings/utils/ldap.py:726
#, python-brace-format
msgid "Error (Invalid Bind DN): {}"
msgstr "错误 (不合法的绑定DN): {}"
#: settings/utils/ldap.py:724
#: settings/utils/ldap.py:728
#, python-brace-format
msgid "Error (Invalid LDAP User attr map): {}"
msgstr "错误 (不合法的LDAP属性映射): {}"
#: settings/utils/ldap.py:726
#: settings/utils/ldap.py:730
#, python-brace-format
msgid "Error (Invalid User OU or User search filter): {}"
msgstr "错误 (不合法的用户OU或用户过滤器): {}"
#: settings/utils/ldap.py:728
#: settings/utils/ldap.py:732
#, python-brace-format
msgid "Error (Not enabled LDAP authentication): {}"
msgstr "错误 (没有启用LDAP认证): {}"
#: settings/utils/ldap.py:730
#: settings/utils/ldap.py:734
#, python-brace-format
msgid "Error (Unknown): {}"
msgstr "错误 (未知): {}"
#: settings/utils/ldap.py:733
#: settings/utils/ldap.py:737
#, python-brace-format
msgid "Succeed: Match {} users"
msgstr "成功匹配 {} 个用户"
#: settings/utils/ldap.py:765
#: settings/utils/ldap.py:769
#, python-brace-format
msgid "Authentication failed (configuration incorrect): {}"
msgstr "认证失败 (配置错误): {}"
#: settings/utils/ldap.py:769
#: settings/utils/ldap.py:773
#, python-brace-format
msgid "Authentication failed (username or password incorrect): {}"
msgstr "认证失败 (用户名或密码不正确): {}"
#: settings/utils/ldap.py:771
#: settings/utils/ldap.py:775
#, python-brace-format
msgid "Authentication failed (Unknown): {}"
msgstr "认证失败: (未知): {}"
#: settings/utils/ldap.py:774
#: settings/utils/ldap.py:778
#, python-brace-format
msgid "Authentication success: {}"
msgstr "认证成功: {}"
@@ -12668,5 +12669,8 @@ msgstr "许可证导入成功"
msgid "Invalid license"
msgstr "许可证无效"
#~ msgid "Waiting task start"
#~ msgstr "等待任务开始"
#~ msgid "Offline video player"
#~ msgstr "离线录像播放器"

View File

@@ -602,6 +602,7 @@ class Config(dict):
'SECURITY_MFA_AUTH_ENABLED_FOR_THIRD_PARTY': True,
'SECURITY_MFA_BY_EMAIL': False,
'SECURITY_COMMAND_EXECUTION': False,
'ANSIBLE_DOCKER_ENABLED': True,
'SECURITY_COMMAND_BLACKLIST': [
'reboot', 'shutdown', 'poweroff', 'halt', 'dd', 'half', 'top'
],

View File

@@ -43,6 +43,7 @@ SECURITY_MFA_BY_EMAIL = CONFIG.SECURITY_MFA_BY_EMAIL
SECURITY_MAX_IDLE_TIME = CONFIG.SECURITY_MAX_IDLE_TIME # Unit: minute
SECURITY_MAX_SESSION_TIME = CONFIG.SECURITY_MAX_SESSION_TIME # Unit: hour
SECURITY_COMMAND_EXECUTION = CONFIG.SECURITY_COMMAND_EXECUTION
ANSIBLE_DOCKER_ENABLED = CONFIG.ANSIBLE_DOCKER_ENABLED
SECURITY_COMMAND_BLACKLIST = CONFIG.SECURITY_COMMAND_BLACKLIST
SECURITY_PASSWORD_EXPIRATION_TIME_ADMIN = CONFIG.SECURITY_PASSWORD_EXPIRATION_TIME_ADMIN # Unit: day
SECURITY_PASSWORD_EXPIRATION_TIME = CONFIG.SECURITY_PASSWORD_EXPIRATION_TIME # Unit: day

View File

@@ -7,7 +7,7 @@ timeout = 65
[privilege_escalation]
[paramiko_connection]
[ssh_connection]
# Docker 隔离下 ControlMaster 在 bind mount 上无法创建 mux socketmacOS 尤其明显)
# for test
ssh_args = -o ControlMaster=no -o ControlPersist=no
pipelining = True

View File

@@ -280,7 +280,8 @@ def main():
login_port=dict(type='int', default=1433),
script=dict(required=True),
output=dict(default='default', choices=['dict', 'default']),
params=dict(type='dict'),
# 防止 params 中的密码出现在日志中
params=dict(type='dict', no_log=True),
transaction=dict(type='bool', default=True),
tds_version=dict(type='str', required=False, default=None),
encryption=dict(type='str', required=False, default=None)

View File

@@ -0,0 +1,76 @@
import os
import shutil
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from common.utils.safe import safe_run_cmd
from .exception import AnsibleDockerImageNotFound
ANSIBLE_EE_IMAGE = 'jumpserver/ansible-executor:latest'
ANSIBLE_EE_PYTHON_INTERPRETER = '/usr/bin/python3.11'
__all__ = [
'ANSIBLE_EE_IMAGE',
'ANSIBLE_EE_PYTHON_INTERPRETER',
'use_ansible_docker_isolation',
'docker_extravars',
'docker_isolation_kwargs',
'prepare_isolated_ansible_cfg',
'stage_inventory_for_docker',
'ensure_ansible_docker_image',
]
def use_ansible_docker_isolation():
return settings.ANSIBLE_DOCKER_ENABLED
def docker_extravars(extra_vars):
extravars = dict(extra_vars or {})
if use_ansible_docker_isolation():
extravars.setdefault('local_python_interpreter', ANSIBLE_EE_PYTHON_INTERPRETER)
return extravars
def docker_isolation_kwargs():
return {
'process_isolation': True,
'process_isolation_executable': 'docker',
'container_image': ANSIBLE_EE_IMAGE,
'container_options': ['--network=jms_net'],
}
def prepare_isolated_ansible_cfg(project_dir):
if not use_ansible_docker_isolation():
return
src = os.path.join(settings.APPS_DIR, 'libs', 'ansible', 'ansible.cfg')
dst = os.path.join(project_dir, 'ansible.cfg')
shutil.copyfile(src, dst)
def stage_inventory_for_docker(project_dir, inventory_path):
if not use_ansible_docker_isolation():
return inventory_path
standard_dir = os.path.join(project_dir, 'inventory')
standard_path = os.path.join(standard_dir, 'hosts')
if os.path.realpath(inventory_path) == os.path.realpath(standard_path):
return standard_path
os.makedirs(standard_dir, mode=0o700, exist_ok=True)
shutil.copy2(inventory_path, standard_path)
return standard_path
def ensure_ansible_docker_image():
if not use_ansible_docker_isolation():
return
result = safe_run_cmd(['docker', 'image', 'inspect', ANSIBLE_EE_IMAGE])
if not result or result.returncode != 0:
raise AnsibleDockerImageNotFound(
_('Ansible Docker image "%(image)s" not found. '
'You can disable this option in System Settings - Feature Settings - Job Center - '
'Ansible Docker isolation to run locally. '
'Please run: docker pull %(image)s')
% {'image': ANSIBLE_EE_IMAGE}
)

View File

@@ -1,5 +1,9 @@
__all__ = ['CommandInBlackListException']
__all__ = ['CommandInBlackListException', 'AnsibleDockerImageNotFound']
class CommandInBlackListException(Exception):
pass
class AnsibleDockerImageNotFound(Exception):
pass

View File

@@ -10,43 +10,25 @@ from common.utils.yml import sanitize_ansible_inventory_json, sanitize_ansible_p
from ..utils import get_ansible_log_verbosity
from .callback import DefaultCallback
from .docker import (
docker_extravars,
docker_isolation_kwargs,
ensure_ansible_docker_image,
prepare_isolated_ansible_cfg,
stage_inventory_for_docker,
use_ansible_docker_isolation,
)
from .exception import CommandInBlackListException
from .interface import interface
__all__ = ['AdHocRunner', 'PlaybookRunner', 'SuperPlaybookRunner', 'UploadFileRunner']
ANSIBLE_EE_IMAGE = 'jumpserver/ansible-executor:latest'
def use_ansible_docker_isolation():
"""Production runs ansible in EE container; dev runs in celery worker."""
return not settings.DEBUG_DEV
def docker_isolation_kwargs():
return {
'process_isolation': True,
'process_isolation_executable': 'docker',
'container_image': ANSIBLE_EE_IMAGE,
'container_options': ['--network=host'],
}
def prepare_isolated_ansible_cfg(project_dir):
"""Copy ansible.cfg into job dir so the EE container picks up SSH settings."""
if not use_ansible_docker_isolation():
return
src = os.path.join(settings.APPS_DIR, 'libs', 'ansible', 'ansible.cfg')
dst = os.path.join(project_dir, 'ansible.cfg')
shutil.copyfile(src, dst)
class AdHocRunner:
cmd_modules_choices = ('shell', 'raw', 'command', 'script', 'win_shell')
need_local_connection_modules_choices = ("mysql", "postgresql", "sqlserver", "huawei")
def __init__(self, inventory, job_module, module, module_args='', pattern='*', project_dir='/tmp/',
extra_vars=None,
def __init__(self, inventory, job_module, module, module_args='', pattern='*', project_dir='/tmp/', extra_vars=None,
dry_run=False, timeout=-1):
if extra_vars is None:
extra_vars = {}
@@ -82,7 +64,7 @@ class AdHocRunner:
verbosity = get_ansible_log_verbosity(verbosity)
if not os.path.exists(self.project_dir):
os.mkdir(self.project_dir, 0o755)
os.makedirs(self.project_dir, 0o755, exist_ok=True)
private_env = safe_join(self.project_dir, 'env')
if os.path.exists(private_env):
shutil.rmtree(private_env)
@@ -91,7 +73,7 @@ class AdHocRunner:
run_kwargs = {
'timeout': self.timeout if self.timeout > 0 else None,
'extravars': self.extra_vars,
'extravars': docker_extravars(self.extra_vars),
'envvars': self.envs,
'host_pattern': self.pattern,
'private_data_dir': self.project_dir,
@@ -105,7 +87,7 @@ class AdHocRunner:
}
if use_ansible_docker_isolation():
run_kwargs.update(docker_isolation_kwargs())
ensure_ansible_docker_image()
interface.run(**run_kwargs)
return self.cb
@@ -135,7 +117,8 @@ class PlaybookRunner:
entry = os.path.basename(self.playbook)
playbook_dir = os.path.dirname(self.playbook)
project_playbook_dir = os.path.join(self.project_dir, "project")
shutil.copytree(playbook_dir, project_playbook_dir, dirs_exist_ok=True)
if os.path.realpath(playbook_dir) != os.path.realpath(project_playbook_dir):
shutil.copytree(playbook_dir, project_playbook_dir, dirs_exist_ok=True)
self.playbook = entry
def prepare_safe_inputs(self):
@@ -169,17 +152,19 @@ class PlaybookRunner:
shutil.rmtree(private_env)
prepare_isolated_ansible_cfg(self.project_dir)
inventory = stage_inventory_for_docker(self.project_dir, self.inventory)
kwargs = dict(kwargs)
if use_ansible_docker_isolation():
kwargs.update(docker_isolation_kwargs())
ensure_ansible_docker_image()
elif self.isolate and not is_macos():
kwargs['process_isolation'] = True
kwargs['process_isolation_executable'] = 'bwrap'
interface.run(
private_data_dir=self.project_dir,
inventory=self.inventory,
inventory=inventory,
playbook=self.playbook,
verbosity=verbosity,
event_handler=self.cb.event_handler,
@@ -187,7 +172,7 @@ class PlaybookRunner:
# Docker EE workdir must be the staged playbook dir (not private_data_dir root).
host_cwd=self.playbook_project_dir,
envvars=self.envs,
extravars=self.extra_vars,
extravars=docker_extravars(self.extra_vars),
**kwargs
)
return self.cb
@@ -224,7 +209,7 @@ class UploadFileRunner:
def run(self, verbosity=0, **kwargs):
if not os.path.exists(self.project_dir):
os.makedirs(self.project_dir, mode=0o755)
os.makedirs(self.project_dir, mode=0o755, exist_ok=True)
prepare_isolated_ansible_cfg(self.project_dir)
src_path = self.stage_upload_files()
@@ -244,11 +229,10 @@ class UploadFileRunner:
}
if use_ansible_docker_isolation():
run_kwargs.update(docker_isolation_kwargs())
ensure_ansible_docker_image()
interface.run(**run_kwargs)
try:
shutil.rmtree(self.share_src_dir)
except OSError as e:
print(f"del upload tmp dir {self.share_src_dir} failed! {e}")
return self.cb
return self.cb

View File

@@ -5,8 +5,11 @@ from io import BytesIO
from urllib.parse import parse_qsl, urlencode, urlparse, urlsplit, urlunsplit
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.mail import EmailMultiAlternatives
from django.http import FileResponse, HttpResponseBadRequest, JsonResponse
from django.http import (
FileResponse, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
@@ -58,30 +61,51 @@ charts_map = {
}
def export_chart_to_pdf(chart_name, sessionid, request=None):
def get_trusted_site_url():
# Playwright navigation and cookie scope must never depend on request Host.
site_url = getattr(settings, 'SITE_URL', '')
if not isinstance(site_url, str):
raise ValueError('Invalid SITE_URL')
site_url = site_url.rstrip('/')
parsed_site = urlparse(site_url)
if (
not site_url or parsed_site.scheme not in ('http', 'https') or
not parsed_site.hostname or parsed_site.username or
parsed_site.password or parsed_site.query or parsed_site.fragment
):
raise ValueError('Invalid SITE_URL')
return site_url, parsed_site
def build_report_url(chart_path):
path = urllib.parse.unquote(chart_path)
if not path.startswith('/ui/#/reports/'):
raise ValueError('Invalid report path')
site_url, _ = get_trusted_site_url()
return site_url + path
def export_chart_to_pdf(chart_name, sessionid, request):
chart_info = charts_map.get(chart_name)
if not chart_info:
return None, None
if request:
url = request.build_absolute_uri(urllib.parse.unquote(chart_info['path']))
else:
url = urllib.parse.unquote(chart_info['path'])
url = build_report_url(chart_info['path'])
_, parsed_site = get_trusted_site_url()
if settings.DEBUG_DEV:
url = url.replace(":8080", ":9528")
if request:
oid = request.COOKIES.get("X-JMS-ORG")
request_data = request.POST if request.method == 'POST' else request.GET
query = dict(parse_qsl(urlsplit(url).query, keep_blank_values=True))
for key, value in request_data.items():
if key in {'chart', 'csrfmiddlewaretoken'}:
continue
query[key] = value
query.setdefault('days', 7)
query['oid'] = oid
parts = list(urlsplit(url))
parts[3] = urlencode(query, doseq=True)
url = urlunsplit(parts)
oid = request.COOKIES.get("X-JMS-ORG")
request_data = request.POST if request.method == 'POST' else request.GET
query = dict(parse_qsl(urlsplit(url).query, keep_blank_values=True))
for key, value in request_data.items():
if key in {'chart', 'csrfmiddlewaretoken'}:
continue
query[key] = value
query.setdefault('days', 7)
query['oid'] = oid
parts = list(urlsplit(url))
parts[3] = urlencode(query, doseq=True)
url = urlunsplit(parts)
with sync_playwright() as p:
lang = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
@@ -92,12 +116,12 @@ def export_chart_to_pdf(chart_name, sessionid, request=None):
ignore_https_errors=True
)
# 设置 sessionid cookie
parsed_url = urlparse(url)
context.add_cookies([
{
'name': settings.SESSION_COOKIE_NAME,
'value': sessionid,
'domain': parsed_url.hostname,
# hostname is derived from SITE_URL and never includes a port.
'domain': parsed_site.hostname,
'path': '/',
'httpOnly': True,
'secure': False, # 如有 https 可改 True
@@ -122,7 +146,7 @@ def export_chart_to_pdf(chart_name, sessionid, request=None):
@method_decorator(csrf_exempt, name='dispatch')
class ExportPdfView(View):
class ExportPdfView(LoginRequiredMixin, View):
def get(self, request):
chart_name = request.GET.get('chart')
return self._handle_export(request, chart_name)
@@ -132,13 +156,20 @@ class ExportPdfView(View):
return self._handle_export(request, chart_name)
def _handle_export(self, request, chart_name):
if not request.user.is_authenticated:
return HttpResponse(status=401)
if not chart_name:
return HttpResponseBadRequest('Missing chart parameter')
sessionid = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
if not sessionid:
return HttpResponseBadRequest('No sessionid found in cookies')
pdf_bytes, title = export_chart_to_pdf(chart_name, sessionid, request=request)
try:
pdf_bytes, title = export_chart_to_pdf(
chart_name, sessionid, request=request
)
except ValueError:
return HttpResponseBadRequest('Invalid report configuration')
if not pdf_bytes:
return HttpResponseBadRequest('Failed to generate PDF')
filename = f"{title}-{timezone.now().strftime('%Y%m%d%H%M%S')}.pdf"
@@ -147,9 +178,11 @@ class ExportPdfView(View):
return response
class SendMailView(View):
class SendMailView(LoginRequiredMixin, View):
def post(self, request):
if not request.user.is_authenticated:
return HttpResponse(status=401)
chart_name = request.GET.get('chart') or request.POST.get('chart')
if not chart_name:
return HttpResponseBadRequest('Missing chart parameter')
@@ -161,7 +194,12 @@ class SendMailView(View):
return HttpResponseBadRequest('No sessionid found in cookies')
# 1. 生成 PDF
pdf_bytes, title = export_chart_to_pdf(chart_name, sessionid, request=request)
try:
pdf_bytes, title = export_chart_to_pdf(
chart_name, sessionid, request=request
)
except ValueError:
return HttpResponseBadRequest('Invalid report configuration')
if not pdf_bytes:
return HttpResponseBadRequest('Failed to generate PDF')
@@ -188,4 +226,4 @@ class SendMailView(View):
msg.send()
except Exception as e:
return JsonResponse({"error": _('Failed to send email: ') + str(e)})
return JsonResponse({"message": _('Email sent successfully to ') + email})
return JsonResponse({"message": _('Email sent successfully to ') + email})

View File

@@ -6,6 +6,7 @@ from rest_framework import serializers
from common.serializers.fields import EncryptedField
from common.utils import date_expired_default
from ops.ansible.docker import ANSIBLE_EE_IMAGE
__all__ = [
'AnnouncementSettingSerializer', 'OpsSettingSerializer', 'VaultSettingSerializer',
@@ -17,6 +18,14 @@ from settings.const import (
ChatAITypeChoices, GPTModelChoices, DeepSeekModelChoices, ChatAIMethodChoices
)
ANSIBLE_DOCKER_HELP_TEXT = _(
'Run Ansible jobs in Docker execution environment (%(image)s). '
'You can disable this option in System Settings - Feature Settings - Job Center - '
'Ansible Docker isolation to run locally. '
'If the image is missing, pull it on the ansible worker: '
'docker pull %(image)s'
) % {'image': ANSIBLE_EE_IMAGE}
class AnnouncementSerializer(serializers.Serializer):
ID = serializers.CharField(required=False, allow_blank=True, allow_null=True)
@@ -204,6 +213,11 @@ class OpsSettingSerializer(serializers.Serializer):
required=False, label=_('Adhoc command'),
help_text=_('Allow users to execute batch commands in the Workbench - Job Center - Adhoc')
)
ANSIBLE_DOCKER_ENABLED = serializers.BooleanField(
required=False,
label=_('Ansible Docker isolation'),
help_text=ANSIBLE_DOCKER_HELP_TEXT,
)
SECURITY_COMMAND_BLACKLIST = serializers.ListField(
child=serializers.CharField(max_length=1024),
label=_('Command blacklist'),

View File

@@ -15,9 +15,9 @@ dependencies:
# 与 pyproject.toml [tool.uv.sources] 保持一致,不要用 PyPI 官方包
ansible_core:
package_pip: https://github.com/jumpserver-dev/ansible/archive/refs/tags/v2.14.1.7.zip
package_pip: https://github.com/jumpserver-dev/ansible/archive/refs/tags/v2.16.18.3.zip
ansible_runner:
package_pip: https://github.com/jumpserver-dev/ansible-runner/archive/refs/tags/2.4.0.1.zip
package_pip: https://github.com/jumpserver-dev/ansible-runner/archive/refs/tags/v2.4.0.2.zip
galaxy:
collections:

View File

@@ -1,10 +1,10 @@
# Aligned with JumpServer pyproject.toml ansible-related Python deps.
paramiko==3.2.0
paramiko==3.5.1
sshtunnel==0.4.0
pywinrm==0.4.3
telnetlib3==4.0.2
mysqlclient==2.2.4
pymssql==2.3.4
pymssql==2.3.10
pymongo==4.6.3
oracledb==1.4.0
pyfreerdp==0.0.2
oracledb==3.4.2
pyfreerdp==0.0.4