Merge branch 'dev' of github.com:jumpserver/jumpserver into dev

This commit is contained in:
ibuler
2026-06-22 14:06:37 +08:00
11 changed files with 694 additions and 56 deletions

View File

@@ -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

View File

@@ -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'
run: |
gh issue edit "${{ github.event.issue.number }}" \
--remove-label "🔔 Pending processing" \
--remove-label "⏳ Pending feedback"
env:
GH_TOKEN: ${{ github.token }}

View File

@@ -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 }}

View File

@@ -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'
run: |
gh issue edit "${{ github.event.issue.number }}" \
--add-label "🔔 Pending processing"
env:
GH_TOKEN: ${{ github.token }}

View File

@@ -515,7 +515,6 @@ class BasePlaybookManager(PlaybookPrepareMixin, BaseManager):
error_text = str(error)
return (
"pexpect.exceptions.TIMEOUT" in error_text
and "exitstatus: 0" in error_text
)
def on_runner_failed(self, runner, e, assets=None, **kwargs):

View File

@@ -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
@@ -162,7 +164,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("<I", 0x00010001) # unknown DWORD value
msgsig += pack("<I", 0x00000001) # unknown DWORD value
msgsig += pack("<I", len(opensslout))
msgsig += opensslout
sigval = base64.b64encode(msgsig).decode("ascii")
parts = []
parts.append("\r\n".join(rdp_settings))
parts.append("signscope:s:" + ",".join(signnames))
parts.append("signature:s:" + sigval)
signed_content = "\r\n".join(parts) + "\r\n"
return signed_content
@staticmethod
def escape_name(name):

View File

@@ -750,6 +750,11 @@ class Config(dict):
'TRUSTED_IP_SOURCE_HEADER': '',
'TRUSTED_IP_VERIFY_SIGNATURE_HEADER': '',
'TRUSTED_IP_VERIFY_KEY_PATH': '',
# rdp sign cert
'RDP_SIGN_ENABLED': False,
'RDP_SIGN_CERT': 'signer.crt',
'RDP_SIGN_CERT_KEY': 'signer.key',
}
old_config_map = {

View File

@@ -282,3 +282,8 @@ TRUSTED_IP_VERIFY_ENABLED = CONFIG.TRUSTED_IP_VERIFY_ENABLED
TRUSTED_IP_SOURCE_HEADER = CONFIG.TRUSTED_IP_SOURCE_HEADER
TRUSTED_IP_VERIFY_SIGNATURE_HEADER = CONFIG.TRUSTED_IP_VERIFY_SIGNATURE_HEADER
TRUSTED_IP_VERIFY_KEY_PATH = CONFIG.TRUSTED_IP_VERIFY_KEY_PATH
# RDP 签名相关
RDP_SIGN_ENABLED = CONFIG.RDP_SIGN_ENABLED
RDP_SIGN_CERT = CONFIG.RDP_SIGN_CERT
RDP_SIGN_CERT_KEY = CONFIG.RDP_SIGN_CERT_KEY

View File

@@ -1,6 +1,9 @@
import os
import re
import signal
import sys
import time
import traceback
from functools import wraps
import paramiko
@@ -79,6 +82,29 @@ def _strip_wrapping_quotes(value):
return value
def _build_switch_state_re():
return re.compile(
r'__JMS_SWITCH__:[^\r\n]*',
flags=re.IGNORECASE,
)
def _shorten_text(value, limit=300):
if value is None:
return value
text = str(value).replace('\r', '\\r').replace('\n', '\\n')
if len(text) <= limit:
return text
return text[:limit] + f'...<{len(text)} chars>'
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",
@@ -98,12 +124,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 '<redacted>'
return command
@property
def channel(self):
@@ -133,15 +195,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 -'
@@ -150,22 +228,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.
@@ -181,7 +306,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
@@ -242,6 +373,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')
@@ -256,13 +393,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
@@ -274,11 +425,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)),
@@ -291,14 +454,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()
@@ -317,4 +491,4 @@ class SSHClient:
if self.client:
self.client.close()
except Exception: # noqa
pass
self._debug('cleanup failed', traceback=traceback.format_exc())

View File

@@ -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:

View File

@@ -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()