diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml index df341f211..322785621 100644 --- a/.github/workflows/issue-close-require.yml +++ b/.github/workflows/issue-close-require.yml @@ -4,17 +4,31 @@ on: schedule: - cron: "0 0 * * *" +permissions: + issues: write + jobs: issue-close-require: runs-on: ubuntu-latest steps: - - name: need reproduce - uses: actions-cool/issues-helper@v2 - with: - actions: 'close-issues' - labels: '⏳ Pending feedback' - inactive-day: 30 - body: | - You haven't provided feedback for over 30 days. - We will close this issue. If you have any further needs, you can reopen it or submit a new issue. - 您超过 30 天未反馈信息,我们将关闭该 issue,如有需求您可以重新打开或者提交新的 issue。 + - name: Close inactive issues pending feedback + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + + cutoff="$(date -u -d '30 days ago' '+%Y-%m-%d')" + query="repo:${GH_REPO} is:issue is:open label:\"⏳ Pending feedback\" updated:<${cutoff}" + + printf '%s\n' \ + "You haven't provided feedback for over 30 days." \ + "We will close this issue. If you have any further needs, you can reopen it or submit a new issue." \ + "您超过 30 天未反馈信息,我们将关闭该 issue,如有需求您可以重新打开或者提交新的 issue。" \ + > "${RUNNER_TEMP}/close-comment.md" + + gh api --method GET --paginate /search/issues -f q="${query}" -f per_page=100 --jq '.items[].number' | + while read -r issue_number; do + gh issue comment "${issue_number}" --repo "${GH_REPO}" --body-file "${RUNNER_TEMP}/close-comment.md" + gh issue close "${issue_number}" --repo "${GH_REPO}" + done diff --git a/.github/workflows/issue-close.yml b/.github/workflows/issue-close.yml index 1ff51f456..a7a2b4a38 100644 --- a/.github/workflows/issue-close.yml +++ b/.github/workflows/issue-close.yml @@ -4,13 +4,18 @@ on: issues: types: [closed] +permissions: + issues: write + jobs: issue-close-remove-labels: runs-on: ubuntu-latest + if: ${{ !github.event.issue.pull_request }} steps: - name: Remove labels - uses: actions-cool/issues-helper@v2 - if: ${{ !github.event.issue.pull_request }} - with: - actions: 'remove-labels' - labels: '🔔 Pending processing,⏳ Pending feedback' \ No newline at end of file + run: | + gh issue edit "${{ github.event.issue.number }}" \ + --remove-label "🔔 Pending processing" \ + --remove-label "⏳ Pending feedback" + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/issue-comment.yml b/.github/workflows/issue-comment.yml index 66c03b04b..1e5d4d7fe 100644 --- a/.github/workflows/issue-comment.yml +++ b/.github/workflows/issue-comment.yml @@ -7,19 +7,23 @@ name: Add issues workflow labels jobs: add-label-if-is-author: runs-on: ubuntu-latest - if: (github.event.issue.user.id == github.event.comment.user.id) && !github.event.issue.pull_request && (github.event.issue.state == 'open') - steps: - - name: Add require handle label - uses: actions-cool/issues-helper@v2 - with: - actions: 'add-labels' - labels: '🔔 Pending processing' + permissions: + issues: write + pull-requests: write - - name: Remove require reply label - uses: actions-cool/issues-helper@v2 - with: - actions: 'remove-labels' - labels: '⏳ Pending feedback' + if: > + (github.event.issue.user.id == github.event.comment.user.id) && + !github.event.issue.pull_request && + (github.event.issue.state == 'open') + + steps: + - name: Update labels + run: | + gh issue edit "${{ github.event.issue.number }}" \ + --add-label "🔔 Pending processing" \ + --remove-label "⏳ Pending feedback" + env: + GH_TOKEN: ${{ github.token }} add-label-if-is-member: runs-on: ubuntu-latest @@ -50,16 +54,11 @@ jobs: - run: "echo comment user: '${{ github.event.comment.user.login }}'" - run: "echo contains? : '${{ contains(steps.member_names.outputs.data, github.event.comment.user.login) }}'" - - name: Add require replay label + - name: Update labels if: contains(steps.member_names.outputs.data, github.event.comment.user.login) - uses: actions-cool/issues-helper@v2 - with: - actions: 'add-labels' - labels: '⏳ Pending feedback' - - - name: Remove require handle label - if: contains(steps.member_names.outputs.data, github.event.comment.user.login) - uses: actions-cool/issues-helper@v2 - with: - actions: 'remove-labels' - labels: '🔔 Pending processing' + run: | + gh issue edit "${{ github.event.issue.number }}" \ + --add-label "⏳ Pending feedback" \ + --remove-label "🔔 Pending processing" + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/issue-open.yml b/.github/workflows/issue-open.yml index b1555e113..550a27d73 100644 --- a/.github/workflows/issue-open.yml +++ b/.github/workflows/issue-open.yml @@ -4,13 +4,17 @@ on: issues: types: [opened] +permissions: + issues: write + jobs: issue-open-add-labels: runs-on: ubuntu-latest + if: ${{ !github.event.issue.pull_request }} steps: - name: Add labels - uses: actions-cool/issues-helper@v2 - if: ${{ !github.event.issue.pull_request }} - with: - actions: 'add-labels' - labels: '🔔 Pending processing' \ No newline at end of file + run: | + gh issue edit "${{ github.event.issue.number }}" \ + --add-label "🔔 Pending processing" + env: + GH_TOKEN: ${{ github.token }} diff --git a/apps/assets/api/favorite_asset.py b/apps/assets/api/favorite_asset.py index 51658114b..0a333be82 100644 --- a/apps/assets/api/favorite_asset.py +++ b/apps/assets/api/favorite_asset.py @@ -4,16 +4,15 @@ from rest_framework_bulk.generics import BulkModelViewSet from common.permissions import IsValidUser from orgs.utils import tmp_to_root_org -from ..models import FavoriteAsset -from ..serializers import FavoriteAssetSerializer +from ..models import FavoriteAsset, FavoriteFolder +from ..serializers import FavoriteAssetSerializer, FavoriteFolderSerializer -__all__ = ['FavoriteAssetViewSet'] +__all__ = ['FavoriteAssetViewSet', 'FavoriteFolderViewSet'] -class FavoriteAssetViewSet(BulkModelViewSet): - serializer_class = FavoriteAssetSerializer +class FavoriteFolderViewSet(BulkModelViewSet): + serializer_class = FavoriteFolderSerializer permission_classes = (IsValidUser,) - filterset_fields = ['asset'] page_no_limit = True def dispatch(self, request, *args, **kwargs): @@ -21,7 +20,23 @@ class FavoriteAssetViewSet(BulkModelViewSet): return super().dispatch(request, *args, **kwargs) def get_queryset(self): - queryset = FavoriteAsset.objects.filter(user=self.request.user) + return FavoriteFolder.objects.filter(user=self.request.user) + + +class FavoriteAssetViewSet(BulkModelViewSet): + serializer_class = FavoriteAssetSerializer + permission_classes = (IsValidUser,) + filterset_fields = ['asset', 'folder'] + page_no_limit = True + + def dispatch(self, request, *args, **kwargs): + with tmp_to_root_org(): + return super().dispatch(request, *args, **kwargs) + + def get_queryset(self): + queryset = FavoriteAsset.objects.filter( + user=self.request.user + ).select_related('asset', 'asset__platform') return queryset def allow_bulk_destroy(self, qs, filtered): diff --git a/apps/assets/automations/base/manager.py b/apps/assets/automations/base/manager.py index f5cf4cae7..3641070bb 100644 --- a/apps/assets/automations/base/manager.py +++ b/apps/assets/automations/base/manager.py @@ -517,8 +517,7 @@ class BasePlaybookManager(PlaybookPrepareMixin, BaseManager): def _is_nonfatal_runner_timeout(error): error_text = str(error) return ( - "pexpect.exceptions.TIMEOUT" in error_text - and "exitstatus: 0" in error_text + "pexpect.exceptions.TIMEOUT" in error_text ) def on_runner_failed(self, runner, e, assets=None, **kwargs): diff --git a/apps/assets/migrations/0020_favoritefolder.py b/apps/assets/migrations/0020_favoritefolder.py new file mode 100644 index 000000000..298699f98 --- /dev/null +++ b/apps/assets/migrations/0020_favoritefolder.py @@ -0,0 +1,44 @@ +# Generated for user custom favorite folders + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('assets', '0019_alter_asset_connectivity'), + ] + + operations = [ + migrations.CreateModel( + name='FavoriteFolder', + fields=[ + ('created_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=128, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('comment', models.TextField(blank=True, default='', verbose_name='Comment')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='assets.favoritefolder')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Favorite folder', + 'unique_together': {('user', 'name', 'parent')}, + }, + ), + migrations.AddField( + model_name='favoriteasset', + name='folder', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='assets.favoritefolder'), + ), + migrations.AlterUniqueTogether( + name='favoriteasset', + unique_together={('user', 'asset', 'folder')}, + ), + ] diff --git a/apps/assets/models/favorite_asset.py b/apps/assets/models/favorite_asset.py index 052551402..dad5e1602 100644 --- a/apps/assets/models/favorite_asset.py +++ b/apps/assets/models/favorite_asset.py @@ -5,15 +5,36 @@ from django.utils.translation import gettext_lazy as _ from common.db.models import JMSBaseModel -__all__ = ['FavoriteAsset'] +__all__ = ['FavoriteAsset', 'FavoriteFolder'] + + +class FavoriteFolder(JMSBaseModel): + """User custom favorite folder, owned by a user, visible across orgs, supports nesting""" + user = models.ForeignKey('users.User', on_delete=models.CASCADE) + name = models.CharField(max_length=128, verbose_name=_("Name")) + parent = models.ForeignKey( + 'self', on_delete=models.CASCADE, + null=True, blank=True, related_name='children' + ) + + class Meta: + unique_together = ('user', 'name', 'parent') + verbose_name = _("Favorite folder") + + def __str__(self): + return self.name class FavoriteAsset(JMSBaseModel): user = models.ForeignKey('users.User', on_delete=models.CASCADE) asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE) + folder = models.ForeignKey( + 'assets.FavoriteFolder', on_delete=models.CASCADE, + null=True, blank=True, related_name='assets' + ) class Meta: - unique_together = ('user', 'asset') + unique_together = ('user', 'asset', 'folder') verbose_name = _("Favorite asset") @classmethod diff --git a/apps/assets/serializers/favorite_asset.py b/apps/assets/serializers/favorite_asset.py index 69c916f05..dfe1e948d 100644 --- a/apps/assets/serializers/favorite_asset.py +++ b/apps/assets/serializers/favorite_asset.py @@ -4,16 +4,57 @@ from rest_framework import serializers from common.serializers import BulkSerializerMixin -from ..models import FavoriteAsset +from ..models import FavoriteAsset, FavoriteFolder -__all__ = ['FavoriteAssetSerializer'] +__all__ = ['FavoriteAssetSerializer', 'FavoriteFolderSerializer'] + + +class FavoriteFolderSerializer(BulkSerializerMixin, serializers.ModelSerializer): + user = serializers.HiddenField( + default=serializers.CurrentUserDefault() + ) + + class Meta: + model = FavoriteFolder + fields = ['id', 'user', 'name', 'parent', 'date_created'] + read_only_fields = ['id', 'date_created'] class FavoriteAssetSerializer(BulkSerializerMixin, serializers.ModelSerializer): user = serializers.HiddenField( default=serializers.CurrentUserDefault() ) + asset_info = serializers.SerializerMethodField() class Meta: model = FavoriteAsset - fields = ['user', 'asset'] + fields = ['user', 'asset', 'folder', 'asset_info'] + + @staticmethod + def _get_icon(asset, platform): + from assets.const import AllTypes + support_types = AllTypes.get_types_values(exclude_custom=True) + if asset.category == 'device': + return 'switch' + if asset.type in support_types: + return asset.type + return 'file' + + def get_asset_info(self, obj): + asset = obj.asset + platform = asset.platform + return { + 'id': str(asset.id), + 'name': asset.name, + 'iconSkin': self._get_icon(asset, platform), + 'chkDisabled': not asset.is_active, + 'meta': { + 'type': 'asset', + 'data': { + 'platform_type': platform.type, + 'org_name': asset.org_name, + 'name': asset.name, + 'address': asset.address, + }, + }, + } diff --git a/apps/assets/urls/api_urls.py b/apps/assets/urls/api_urls.py index 7527be88e..472f5f562 100644 --- a/apps/assets/urls/api_urls.py +++ b/apps/assets/urls/api_urls.py @@ -23,6 +23,7 @@ router.register(r'nodes', api.NodeViewSet, 'node') router.register(r'zones', api.ZoneViewSet, 'zone') router.register(r'gateways', api.GatewayViewSet, 'gateway') router.register(r'favorite-assets', api.FavoriteAssetViewSet, 'favorite-asset') +router.register(r'favorite-folders', api.FavoriteFolderViewSet, 'favorite-folder') router.register(r'protocol-settings', api.PlatformProtocolViewSet, 'protocol-setting') router.register(r'labels', LabelViewSet, 'label') router.register(r'my-asset', api.MyAssetViewSet, 'my-asset') diff --git a/apps/audits/signal_handlers/operate_log.py b/apps/audits/signal_handlers/operate_log.py index 18b949249..9a5c67685 100644 --- a/apps/audits/signal_handlers/operate_log.py +++ b/apps/audits/signal_handlers/operate_log.py @@ -109,18 +109,14 @@ def signal_of_operate_log_whether_continue( condition = False if instance and getattr(instance, OP_LOG_SKIP_SIGNAL, False): condition = False - # 不记录组件的操作日志 user = current_request.user if current_request else None if not user or getattr(user, 'is_service_account', False): condition = False - # 终端模型的 create 事件由系统产生,不记录 if instance._meta.object_name == 'Terminal' and created: condition = False - # last_login 改变是最后登录日期, 每次登录都会改变 if instance._meta.object_name == 'User' and \ update_fields and 'last_login' in update_fields: condition = False - # 不在记录白名单中,跳过 if sender._meta.object_name not in MODELS_NEED_RECORD: condition = False return condition @@ -137,7 +133,6 @@ def on_object_pre_create_or_update( return with translation.override('en'): - # users.PrivateToken Model 没有 id 有 pk字段 instance_id = getattr(instance, 'id', getattr(instance, 'pk', None)) instance_before_data = {'id': instance_id} raw_instance = type(instance).objects.filter(pk=instance_id).first() @@ -217,7 +212,7 @@ def on_django_start_set_operate_log_monitor_models(sender, **kwargs): 'PermedAsset', 'PermedAccount', 'MenuPermission', 'Permission', 'TicketSession', 'ApplyLoginTicket', 'ApplyCommandTicket', 'ApplyLoginAssetTicket', - 'FavoriteAsset', 'ChangeSecretRecord', 'AppProvider', 'Variable', 'LeakPasswords' + 'FavoriteAsset', 'FavoriteFolder', 'ChangeSecretRecord', 'AppProvider', 'Variable', 'LeakPasswords' } include_models = {'UserSession'} for i, app in enumerate(apps.get_models(), 1): diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 9c287850c..b50325a98 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -2,6 +2,8 @@ import base64 import json import os import urllib.parse +import subprocess +from struct import pack from django.conf import settings from django.http import HttpResponse @@ -163,7 +165,166 @@ class RDPFileClientProtocolURLMixin: for k, v in rdp_options.items(): content += f'{k}:{v}\n' + if settings.RDP_SIGN_ENABLED: + signed_content = self.signed_rdp_content(content) + if signed_content: + content = signed_content + return filename, content + + @staticmethod + def signed_rdp_content(rdp_file_content: str): + cert_dir = os.path.join(settings.PROJECT_DIR, 'data', 'certs') + if not os.path.exists(cert_dir): + logger.error(f'rdp sign cert dir [{cert_dir}] not exists') + return None + + crt_path = os.path.join(cert_dir, settings.RDP_SIGN_CERT) + if not os.path.exists(crt_path): + logger.error(f'rdp sign cert file [{crt_path}] not exists') + return None + + key_path = os.path.join(cert_dir, settings.RDP_SIGN_CERT_KEY) + if not os.path.exists(key_path): + logger.warning(f'rdp sign cert file [{key_path}] not exists') + key_path = None + + securesettings = [ + ["full address:s:", "Full Address"], + ["alternate full address:s:", "Alternate Full Address"], + ["pcb:s:", "PCB"], + ["use redirection server name:i:", "Use Redirection Server Name"], + ["server port:i:", "Server Port"], + ["negotiate security layer:i:", "Negotiate Security Layer"], + ["enablecredsspsupport:i:", "EnableCredSspSupport"], + ["disableconnectionsharing:i:", "DisableConnectionSharing"], + ["autoreconnection enabled:i:", "AutoReconnection Enabled"], + ["gatewayhostname:s:", "GatewayHostname"], + ["gatewayusagemethod:i:", "GatewayUsageMethod"], + ["gatewayprofileusagemethod:i:", "GatewayProfileUsageMethod"], + ["gatewaycredentialssource:i:", "GatewayCredentialsSource"], + ["support url:s:", "Support URL"], + ["promptcredentialonce:i:", "PromptCredentialOnce"], + ["require pre-authentication:i:", "Require pre-authentication"], + ["pre-authentication server address:s:", "Pre-authentication server address"], + ["alternate shell:s:", "Alternate Shell"], + ["shell working directory:s:", "Shell Working Directory"], + ["remoteapplicationprogram:s:", "RemoteApplicationProgram"], + ["remoteapplicationexpandworkingdir:s:", "RemoteApplicationExpandWorkingdir"], + ["remoteapplicationmode:i:", "RemoteApplicationMode"], + ["remoteapplicationguid:s:", "RemoteApplicationGuid"], + ["remoteapplicationname:s:", "RemoteApplicationName"], + ["remoteapplicationicon:s:", "RemoteApplicationIcon"], + ["remoteapplicationfile:s:", "RemoteApplicationFile"], + ["remoteapplicationfileextensions:s:", "RemoteApplicationFileExtensions"], + ["remoteapplicationcmdline:s:", "RemoteApplicationCmdLine"], + ["remoteapplicationexpandcmdline:s:", "RemoteApplicationExpandCmdLine"], + ["prompt for credentials:i:", "Prompt For Credentials"], + ["authentication level:i:", "Authentication Level"], + ["audiomode:i:", "AudioMode"], + ["redirectdrives:i:", "RedirectDrives"], + ["redirectprinters:i:", "RedirectPrinters"], + ["redirectcomports:i:", "RedirectCOMPorts"], + ["redirectsmartcards:i:", "RedirectSmartCards"], + ["redirectposdevices:i:", "RedirectPOSDevices"], + ["redirectclipboard:i:", "RedirectClipboard"], + ["devicestoredirect:s:", "DevicesToRedirect"], + ["drivestoredirect:s:", "DrivesToRedirect"], + ["loadbalanceinfo:s:", "LoadBalanceInfo"], + ["redirectdirectx:i:", "RedirectDirectX"], + ["rdgiskdcproxy:i:", "RDGIsKDCProxy"], + ["kdcproxyname:s:", "KDCProxyName"], + ["eventloguploadaddress:s:", "EventLogUploadAddress"], + ] + + rdp_settings = list() + signlines = list() + signnames = list() + + lines = [v.strip() for v in rdp_file_content.splitlines()] + + fulladdress = None + alternatefulladdress = None + + for v in lines: + if v.startswith("full address:s:"): + fulladdress = v[15:] + elif v.startswith("alternate full address:s:"): + alternatefulladdress = v[25:] + elif v.startswith("signature:s:"): + continue + elif v.startswith("signscope:s:"): + continue + rdp_settings.append(v) + + # prevent hacks via alternate full address + if fulladdress and not alternatefulladdress: + rdp_settings.append("alternate full address:s:" + fulladdress) + + for s in securesettings: + for v in rdp_settings: + if v.startswith(s[0]): + signnames.append(s[1]) + signlines.append(v) + + msgtext = ( + "\r\n".join(signlines) + + "\r\n" + + "signscope:s:" + + ",".join(signnames) + + "\r\n" + + "\x00" + ) + + msgblob = msgtext.encode("UTF-16LE") + + params = ["openssl", "smime", "-sign", "-binary"] + params += ["-signer", crt_path] + params += ["-outform", "DER"] + params += ["-noattr", "-nosmimecap"] + + if key_path is not None: + params += ["-inkey", key_path] + + try: + proc = subprocess.Popen( + params, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + opensslout, opensslerr = proc.communicate(msgblob) + except OSError as e: + logger.error("Error calling openssl command: %s", e.strerror) + return None + + retcode = proc.poll() + + if retcode != 0: + emsg = "openssl command failed (return code #{0:d})".format(retcode) + if opensslerr is not None: + emsg += ":\n" + emsg += opensslerr.decode("utf-8", errors="replace") + logger.error(emsg) + return None + + # The Microsoft rdpsign.exe adds a 12 byte header to the signature + # before it gets base64 encoded + # The meaning of the first 8 bytes is still unknown + msgsig = pack("' + + +def _extract_switch_state(output): + if not output: + return None + matches = re.findall(r'__JMS_SWITCH__:[^\r\n]*', output) + return matches[-1] if matches else None + + class OldSSHTransport(paramiko.transport.Transport): _preferred_pubkeys = ( "ssh-ed25519", @@ -107,12 +133,48 @@ class SSHClient: self.gateway_server = None self.client = paramiko.SSHClient() self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.debug_enabled = ( + str(os.environ.get('JMS_REMOTE_CLIENT_DEBUG', '')).lower() + in {'1', 'true', 'yes', 'on'} + ) self.connect_params = self.get_connect_params() self._channel = None self.buffer_size = 1024 self.prompt = self.module.params['prompt'] self.timeout = self.module.params['recv_timeout'] + self._debug( + 'init', + login_host=self.module.params.get('login_host'), + login_port=self.module.params.get('login_port'), + login_user=self.module.params.get('login_user'), + become=self.module.params.get('become'), + become_method=self.module.params.get('become_method'), + become_user=self.module.params.get('become_user'), + has_gateway=bool(self.module.params.get('gateway_args')), + old_ssh_version=self.module.params.get('old_ssh_version'), + ) + + def _debug(self, event, **kwargs): + if not self.debug_enabled: + return + details = ', '.join( + f'{key}={_shorten_text(value)}' + for key, value in kwargs.items() + ) + message = f'[remote_client] {event}' + if details: + message += f' | {details}' + print(message, file=sys.stderr, flush=True) + + def _sanitize_command(self, command): + secrets = { + self.module.params.get('login_password'), + self.module.params.get('become_password'), + } + if command and command in secrets: + return '' + return command @property def channel(self): @@ -151,15 +213,31 @@ class SSHClient: if p['old_ssh_version']: params['transport_factory'] = OldSSHTransport + self._debug( + 'connect params prepared', + hostname=params.get('hostname'), + port=params.get('port'), + username=params.get('username'), + has_password=bool(params.get('password')), + key_filename=params.get('key_filename'), + transport_factory=getattr(params.get('transport_factory'), '__name__', None), + ) return params def switch_user(self): p = self.module.params if not p['become']: + self._debug('switch user skipped', reason='become disabled') return method = p['become_method'] username = p['login_user'] + self._debug( + 'switch user start', + method=method, + connect_as=self.connect_params.get('username'), + target_user=username, + ) if method == 'sudo': switch_cmd = 'sudo su -' @@ -168,22 +246,69 @@ class SSHClient: switch_cmd = 'su -' pword = p['login_password'] else: + self._debug('switch user unsupported', method=method) self.module.fail_json(msg=f'Become method {method} not supported.') return - # Expected to see a prompt, type the password, and check the username - output, error = self.execute( - [f'{switch_cmd} {username}', pword, 'whoami'], - [become_prompt_re, DEFAULT_RE, username] + # Username-based verification is unreliable for UID 0 alias accounts: + # `su - useradmin` may legitimately land in a shell that reports + # `root/root` for USER and LOGNAME. Compare shell state before and + # after `su` instead; if password auth fails, the marker runs in the + # original shell and the state stays unchanged. + switch_state_cmd = 'printf "__JMS_SWITCH__:%s:%s:%s\\n" "$USER" "$LOGNAME" "$HOME"' + switch_state_re = _build_switch_state_re() + + baseline_output, baseline_error = self.execute( + [switch_state_cmd], + [switch_state_re] ) + baseline_state = _extract_switch_state(baseline_output) + self._debug( + 'switch user baseline', + output=baseline_output, + error=baseline_error, + state=baseline_state, + ) + if baseline_error: + self.module.fail_json(msg=f'Failed to capture shell state before switching user. Output: {baseline_output}') + + # Expected to see a prompt, type the password, and verify the target + # shell state is no longer the original login shell. + output, error = self.execute( + [f'{switch_cmd} {username}', pword, switch_state_cmd], + [become_prompt_re, DEFAULT_RE, switch_state_re] + ) + switched_state = _extract_switch_state(output) + self._debug('switch user result', output=output, error=error) if error: self.module.fail_json(msg=f'Failed to become user {username}. Output: {output}') + if baseline_state == switched_state: + self.module.fail_json( + msg=( + f'Failed to become user {username}. ' + f'Shell state did not change. Output: {output}' + ) + ) def connect(self): - self.before_runner_start() try: + self._debug( + 'connect start', + hostname=self.connect_params.get('hostname'), + port=self.connect_params.get('port'), + username=self.connect_params.get('username'), + ) + self.before_runner_start() + self._debug( + 'connect after gateway prepare', + hostname=self.connect_params.get('hostname'), + port=self.connect_params.get('port'), + username=self.connect_params.get('username'), + ) self.client.connect(**self.connect_params) + self._debug('client.connect ok') self._channel = self.client.invoke_shell() + self._debug('invoke_shell ok') # Always perform a gentle handshake that works for servers and # network devices: drain banner, brief settle, send newline, then # read in quiet mode to avoid blocking on missing prompt. @@ -199,7 +324,13 @@ class SSHClient: pass self._get_match_recv() self.switch_user() + self._debug('connect complete') except Exception as error: + self._debug( + 'connect failed', + error=str(error), + traceback=traceback.format_exc(), + ) self.module.fail_json(msg=str(error)) @staticmethod @@ -260,6 +391,12 @@ class SSHClient: prev_str = buffer_str time.sleep(0.01) + self._debug( + 'recv complete', + use_regex_match=use_regex_match, + check_reg=check_reg, + output=buffer_str, + ) return buffer_str @raise_timeout('Wait send message') @@ -274,13 +411,27 @@ class SSHClient: try: answers = self._fit_answers(commands, answers) - for cmd, ans_regex in zip(commands, answers): + self._debug('execute start', total_commands=len(commands)) + for index, (cmd, ans_regex) in enumerate(zip(commands, answers), start=1): self._check_send() + self._debug( + 'execute send', + index=index, + command=self._sanitize_command(cmd), + answer_reg=ans_regex, + ) self.channel.send(cmd + '\n') - combined_output += self._get_match_recv(ans_regex) + '\n' + output = self._get_match_recv(ans_regex) + combined_output += output + '\n' + self._debug('execute recv', index=index, output=output) except Exception as e: error_msg = str(e) + self._debug( + 'execute failed', + error=error_msg, + traceback=traceback.format_exc(), + ) return combined_output, error_msg @@ -293,11 +444,23 @@ class SSHClient: ) match = re.search(pattern, gateway_args) if not match: + if gateway_args: + self._debug('gateway parse skipped', gateway_args=gateway_args) return password, port, username, remote_addr, key_path = match.groups() password = _strip_wrapping_quotes(password) or None key_path = _strip_wrapping_quotes(key_path) or None + self._debug( + 'gateway parsed', + gateway_host=remote_addr, + gateway_port=port, + gateway_user=username, + has_password=bool(password), + key_path=key_path, + remote_bind_host=self.module.params['login_host'], + remote_bind_port=self.module.params['login_port'], + ) server = SSHTunnelForwarder( (remote_addr, int(port)), @@ -310,14 +473,25 @@ class SSHClient: ) ) - server.start() + try: + server.start() + except Exception: + self._debug('gateway start failed', traceback=traceback.format_exc()) + raise self.connect_params['hostname'] = '127.0.0.1' self.connect_params['port'] = server.local_bind_port self.gateway_server = server + self._debug( + 'gateway start ok', + local_bind_host=self.connect_params['hostname'], + local_bind_port=self.connect_params['port'], + ) def local_gateway_clean(self): if self.gateway_server: + self._debug('gateway stop start') self.gateway_server.stop() + self._debug('gateway stop ok') def before_runner_start(self): self.local_gateway_prepare() @@ -336,4 +510,4 @@ class SSHClient: if self.client: self.client.close() except Exception: # noqa - pass + self._debug('cleanup failed', traceback=traceback.format_exc()) diff --git a/apps/rbac/const.py b/apps/rbac/const.py index e6205a213..8152e468c 100644 --- a/apps/rbac/const.py +++ b/apps/rbac/const.py @@ -37,6 +37,7 @@ exclude_permissions = ( ('assets', 'cluster', '*', '*'), ('assets', 'systemuser', '*', '*'), ('assets', 'favoriteasset', '*', '*'), + ('assets', 'favoritefolder', '*', '*'), ('assets', 'assetuser', '*', '*'), ('assets', 'web', '*', '*'), ('assets', 'host', '*', '*'), diff --git a/apps/terminal/models/applet/applet.py b/apps/terminal/models/applet/applet.py index 694a9e700..e6b7af388 100644 --- a/apps/terminal/models/applet/applet.py +++ b/apps/terminal/models/applet/applet.py @@ -299,7 +299,7 @@ class Applet(JMSBaseModel): def _try_dc_private_account(user, host): if not host.joined_dir_svcs: return None - account = host.dc_accounts.filter(username=user.username).first() + account = host.dc_accounts.filter(username=user.username, is_active=True).first() return account def _select_a_private_account(self, user, host, valid_accounts): @@ -353,7 +353,8 @@ class Applet(JMSBaseModel): if not host: return None logger.info('Select applet host: {}'.format(host.name)) - valid_accounts = host.accounts.all().filter(privileged=False) + # 过滤掉未激活的账号 + valid_accounts = host.accounts.all().filter(privileged=False, is_active=True) account = self.try_to_use_private_account(user, host, valid_accounts) if not account: logger.debug('No private account, try to use public account') diff --git a/apps/terminal/models/session/sharing.py b/apps/terminal/models/session/sharing.py index 50ac1c18f..9d50613f2 100644 --- a/apps/terminal/models/session/sharing.py +++ b/apps/terminal/models/session/sharing.py @@ -9,7 +9,7 @@ from common.db.models import JMSBaseModel from common.utils import is_uuid from orgs.mixins.models import OrgModelMixin from orgs.utils import tmp_to_root_org -from terminal.const import LoginFrom +from terminal.const import LoginFrom, TerminalType from users.models import User __all__ = ['SessionSharing', 'SessionJoinRecord'] @@ -47,9 +47,18 @@ class SessionSharing(JMSBaseModel, OrgModelMixin): def __str__(self): return 'Creator: {}'.format(self.creator) + @property + def share_component(self) -> str: + terminal = self.session.terminal + if terminal: + return terminal.type + if self.session.protocol in ('vnc', 'rdp'): + return TerminalType.lion + return TerminalType.koko + @cached_property def url(self) -> str: - return '%s/koko/share/%s/' % (self.origin, self.id) + return '%s/%s/share/%s/' % (self.origin, self.share_component, self.id) @cached_property def users_display(self) -> list: diff --git a/utils/debug_remote_client.py b/utils/debug_remote_client.py new file mode 100644 index 000000000..2d8eb6855 --- /dev/null +++ b/utils/debug_remote_client.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import signal +import sys +import traceback +from pathlib import Path + + +def find_root_dir(): + current = Path(__file__).resolve().parent + for candidate in [current, *current.parents]: + apps_dir = candidate / 'apps' + remote_client_file = apps_dir / 'libs' / 'ansible' / 'modules_utils' / 'remote_client.py' + if remote_client_file.exists(): + return candidate + raise RuntimeError('Could not locate project root containing apps/libs/ansible/modules_utils/remote_client.py') + + +ROOT_DIR = find_root_dir() +APPS_DIR = ROOT_DIR / 'apps' +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) +if str(APPS_DIR) not in sys.path: + sys.path.insert(0, str(APPS_DIR)) + +from libs.ansible.modules_utils.remote_client import SSHClient # noqa: E402 + + +class DummyModule: + def __init__(self, params): + self.params = params + + def fail_json(self, **kwargs): + raise RuntimeError(kwargs['msg']) + + +def mask(value): + return '***' if value else value + + +def load_inventory(args): + if args.inventory_file: + with open(args.inventory_file, 'r', encoding='utf-8') as f: + return json.load(f) + + if not sys.stdin.isatty(): + return json.load(sys.stdin) + + raise SystemExit('Provide --inventory-file or pipe inventory JSON to stdin.') + + +def get_target_host(inventory, host_name=None): + hosts = inventory.get('all', {}).get('hosts', {}) + if host_name: + try: + return host_name, hosts[host_name] + except KeyError as exc: + available = ', '.join(sorted(hosts)) + raise SystemExit(f'Host {host_name!r} not found. Available: {available}') from exc + + for name, host in hosts.items(): + if name != 'localhost': + return name, host + raise SystemExit('No remote hosts found in inventory JSON.') + + +def build_change_secret_privileged_params(host): + jms_asset = host['jms_asset'] + jms_account = host['jms_account'] + host_params = host.get('params', {}) + + return { + 'login_host': jms_asset['address'], + 'login_port': jms_asset['port'], + 'login_user': jms_account['username'], + 'login_password': jms_account['secret'], + 'login_secret_type': jms_account['secret_type'], + 'login_private_key_path': jms_account['private_key_path'], + 'gateway_args': jms_asset.get('ansible_ssh_common_args', ''), + 'recv_timeout': host_params.get('recv_timeout', 30), + 'delay_time': host_params.get('delay_time', 2), + 'prompt': host_params.get('prompt', '.*'), + 'answers': host_params.get('answers', '.*'), + 'commands': None, + 'become': host.get('jms_custom_become', False), + 'become_method': host.get('jms_custom_become_method', 'su'), + 'become_user': host.get('jms_custom_become_user', ''), + 'become_password': host.get('jms_custom_become_password', ''), + 'become_private_key_path': host.get('jms_custom_become_private_key_path'), + 'old_ssh_version': jms_asset.get('old_ssh_version', False), + } + + +def print_effective_params(host_name, params): + print(f'host = {host_name}') + print( + json.dumps( + { + 'login_host': params['login_host'], + 'login_port': params['login_port'], + 'login_user': params['login_user'], + 'login_password': mask(params['login_password']), + 'login_secret_type': params['login_secret_type'], + 'login_private_key_path': params['login_private_key_path'], + 'become': params['become'], + 'become_method': params['become_method'], + 'become_user': params['become_user'], + 'become_password': mask(params['become_password']), + 'become_private_key_path': params['become_private_key_path'], + 'old_ssh_version': params['old_ssh_version'], + 'gateway_args': params['gateway_args'], + 'recv_timeout': params['recv_timeout'], + }, + ensure_ascii=False, + indent=2, + ) + ) + + +def run_with_timeout(timeout, step_name, func): + if timeout <= 0: + return func() + + def handler(signum, frame): + raise TimeoutError(f'{step_name} timed out after {timeout}s') + + previous = signal.signal(signal.SIGALRM, handler) + try: + signal.alarm(timeout) + return func() + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, previous) + + +def run_client(params, args): + module = DummyModule(params) + with SSHClient(module) as client: + client.connect_params.update( + { + 'timeout': args.connect_timeout, + 'banner_timeout': args.banner_timeout, + 'auth_timeout': args.auth_timeout, + } + ) + print( + 'connect_params =', + json.dumps( + { + 'hostname': client.connect_params.get('hostname'), + 'port': client.connect_params.get('port'), + 'username': client.connect_params.get('username'), + 'password': mask(client.connect_params.get('password')), + 'key_filename': client.connect_params.get('key_filename'), + 'transport_factory': getattr( + client.connect_params.get('transport_factory'), + '__name__', + None, + ), + 'timeout': client.connect_params.get('timeout'), + 'banner_timeout': client.connect_params.get('banner_timeout'), + 'auth_timeout': client.connect_params.get('auth_timeout'), + }, + ensure_ascii=False, + ), + ) + run_with_timeout( + args.overall_connect_timeout, + 'SSH connect', + client.connect, + ) + if args.command: + output, error = client.execute([args.command], ['.*']) + print('command =', args.command) + print('output =', output) + print('error =', error) + + +def parse_args(): + parser = argparse.ArgumentParser( + description='Debug remote_client.py using JumpServer inventory JSON.', + ) + parser.add_argument( + '--inventory-file', + help='Path to a hosts.json or equivalent inventory JSON file.', + ) + parser.add_argument( + '--host', + help='Inventory host key, for example: dqyhd009010(useradmin)', + ) + parser.add_argument( + '--command', + default='whoami', + help='Optional command to run after connect(); default: whoami', + ) + parser.add_argument( + '--connect-timeout', + type=int, + default=10, + help='Socket connect timeout passed to paramiko.connect(); default: 10', + ) + parser.add_argument( + '--banner-timeout', + type=int, + default=10, + help='Banner timeout passed to paramiko.connect(); default: 10', + ) + parser.add_argument( + '--auth-timeout', + type=int, + default=10, + help='Authentication timeout passed to paramiko.connect(); default: 10', + ) + parser.add_argument( + '--overall-connect-timeout', + type=int, + default=20, + help='Hard timeout around client.connect(); set 0 to disable; default: 20', + ) + parser.add_argument( + '--no-debug', + action='store_true', + help='Do not enable JMS_REMOTE_CLIENT_DEBUG automatically.', + ) + return parser.parse_args() + + +def main(): + args = parse_args() + if not args.no_debug: + os.environ.setdefault('JMS_REMOTE_CLIENT_DEBUG', '1') + + inventory = load_inventory(args) + host_name, host = get_target_host(inventory, args.host) + params = build_change_secret_privileged_params(host) + + print_effective_params(host_name, params) + print('flow = ssh as become_user -> su/sudo to login_user') + + try: + run_client(params, args) + except Exception as exc: + print(f'error = {exc}', file=sys.stderr) + print(traceback.format_exc(), file=sys.stderr) + raise SystemExit(1) from exc + + +''' +docker cp ./hosts.json jms_core:/opt/jumpserver/ +docker cp ./debug_remote_client.py jms_core:/opt/jumpserver/ + +root@jms_core:/opt/jumpserver# pwd +/opt/jumpserver + +dqyhd009010(useradmin) is an example host key from inventory JSON, replace it with your actual host key. + +PYTHONPATH=/opt/jumpserver/apps JMS_REMOTE_CLIENT_DEBUG=1 python debug_remote_client.py --inventory-file hosts.json --host 'dqyhd009010(useradmin)' --command 'whoami' --connect-timeout 5 --banner-timeout 5 --auth-timeout 5 --overall-connect-timeout 15 +''' + +if __name__ == '__main__': + main()