Skip to content

Commit 931a898

Browse files
authored
Win PTY implementation for ssh connections in windows
* Win PTY implementation for ssh connections in windows * Win PTY implementation for ssh connections in windows
1 parent 0817fe1 commit 931a898

File tree

5 files changed

+473
-206
lines changed

5 files changed

+473
-206
lines changed

ark_sdk_python/common/connections/ssh/ark_pty_ssh_connection.py

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def connect(self, connection_details: ArkConnectionDetails) -> None:
3333
Raises:
3434
ArkException: _description_
3535
"""
36+
# pylint: disable=import-error
3637
from pexpect import pxssh
3738

3839
if self.__is_connected:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import os
2+
import re
3+
import subprocess
4+
import threading
5+
import time
6+
from shutil import which
7+
from typing import Any, Final, Optional
8+
9+
from overrides import overrides
10+
11+
from ark_sdk_python.common.connections.ark_connection import ArkConnection
12+
from ark_sdk_python.common.connections.ssh.ark_ssh_connection import SSH_PORT
13+
from ark_sdk_python.models import ArkException
14+
from ark_sdk_python.models.common.connections import ArkConnectionCommand, ArkConnectionDetails, ArkConnectionResult
15+
16+
17+
class ArkPTYSSHWinConnection(ArkConnection):
18+
__ANSI_COLOR_STRIPPER: Final[Any] = re.compile(r'\x1b[^m]*m|\x1b\[\?2004[hl]')
19+
__ANSI_ESCAPE_STRIPPER: Final[Any] = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]')
20+
__PROMPT_REGEX: Final[Any] = re.compile(r"[#$]")
21+
__PASSWORD_PROMPT_REGEX: Final[Any] = re.compile(
22+
r"(?i)(?:password:)|(?:passphrase for key)|(?:^Please enter your password[\s\S]+.+ >:$)"
23+
)
24+
__EXIT_MAKRER: Final[str] = '__ARK_RC_MARK__'
25+
__DEFAULT_COL_SIZE: Final[int] = 80
26+
__DEFAULT_ROW_SIZE: Final[int] = 24
27+
__DEFAULT_NONEAGER_TIMEOEUT: Final[float] = 1.0
28+
__DEFAULT_PROMPT_OVERALL_TIMEOUT: Final[float] = 20.0
29+
__CHAR_SLEEP_TIME: Final[float] = 0.05
30+
31+
def __init__(self):
32+
super().__init__()
33+
self.__is_connected: bool = False
34+
self.__is_suspended: bool = False
35+
self.__pty: Optional[Any] = None
36+
self.__output_lock: threading.Lock = threading.Lock()
37+
self.__buffer: str = ''
38+
39+
def __strip_ansi(self, ansi_input: str) -> str:
40+
ansi_input = ansi_input.strip()
41+
ansi_input = ArkPTYSSHWinConnection.__ANSI_COLOR_STRIPPER.sub('', ansi_input)
42+
ansi_input = ArkPTYSSHWinConnection.__ANSI_ESCAPE_STRIPPER.sub('', ansi_input)
43+
return ansi_input
44+
45+
def __reset_buffer(self) -> None:
46+
with self.__output_lock:
47+
self.__buffer = ''
48+
49+
def __read_until_latest_prompt(
50+
self,
51+
password: Optional[str] = None,
52+
noneager_timeout: float = __DEFAULT_NONEAGER_TIMEOEUT,
53+
overall_timeout: float = __DEFAULT_PROMPT_OVERALL_TIMEOUT,
54+
login_prompt: bool = False,
55+
expected_prompt: Any = __PROMPT_REGEX,
56+
) -> None:
57+
buffer = ''
58+
prompt_found_at = -1
59+
start_time = time.time()
60+
noneager_start_time = start_time
61+
62+
while True:
63+
char = self.__pty.read(1)
64+
if not char:
65+
if ArkPTYSSHWinConnection.__PASSWORD_PROMPT_REGEX.search(self.__strip_ansi(buffer)) and not password and login_prompt:
66+
raise RuntimeError(f'Password prompt with no password given [{buffer}]')
67+
if (time.time() - noneager_start_time > noneager_timeout) and prompt_found_at != -1:
68+
break
69+
if (time.time() - start_time) > overall_timeout and prompt_found_at == -1:
70+
raise RuntimeError(f'Timeout while waiting for prompt [{buffer}]')
71+
time.sleep(ArkPTYSSHWinConnection.__CHAR_SLEEP_TIME)
72+
continue
73+
74+
buffer += char
75+
76+
with self.__output_lock:
77+
self.__buffer += char
78+
79+
if ArkPTYSSHWinConnection.__PASSWORD_PROMPT_REGEX.search(self.__strip_ansi(buffer)) and login_prompt:
80+
if not password:
81+
raise RuntimeError(f'Password prompt with no password given [{buffer}]')
82+
self.__pty.write(password + '\n')
83+
buffer = ""
84+
prompt_found_at = -1
85+
continue
86+
87+
if expected_prompt.search(buffer):
88+
prompt_found_at = len(buffer)
89+
90+
noneager_start_time = time.time()
91+
92+
if prompt_found_at != -1:
93+
with self.__output_lock:
94+
self.__buffer = buffer
95+
96+
@overrides
97+
def connect(self, connection_details: ArkConnectionDetails) -> None:
98+
"""
99+
Performs SSH connection with given details or keys
100+
Saves the ssh session to be used for command executions
101+
Done using windows pty
102+
103+
Args:
104+
connection_details (ArkConnectionDetails): _description_
105+
106+
Raises:
107+
ArkException: _description_
108+
"""
109+
# pylint: disable=import-error
110+
import winpty
111+
112+
if self.__is_connected:
113+
return
114+
115+
address = connection_details.address
116+
user = connection_details.credentials.user
117+
port = connection_details.port or SSH_PORT
118+
password = None
119+
key_path = None
120+
if connection_details.credentials.password:
121+
password = connection_details.credentials.password.get_secret_value()
122+
elif connection_details.credentials.private_key_filepath:
123+
key_path = connection_details.credentials.private_key_filepath
124+
125+
ssh_args = [f'{user}@{address}', '-p', str(port), '-o', 'StrictHostKeyChecking=no']
126+
if key_path:
127+
ssh_args.extend(['-i', str(key_path)])
128+
try:
129+
self.__pty = winpty.PTY(
130+
cols=ArkPTYSSHWinConnection.__DEFAULT_COL_SIZE,
131+
rows=ArkPTYSSHWinConnection.__DEFAULT_ROW_SIZE,
132+
backend=winpty.enums.Backend.WinPTY,
133+
)
134+
ssh_full_cmd = which('ssh', path=os.environ.get('PATH', os.defpath))
135+
self.__pty.spawn(ssh_full_cmd, cmdline=' ' + subprocess.list2cmdline(ssh_args))
136+
self.__read_until_latest_prompt(password, login_prompt=True)
137+
self.__is_connected = True
138+
except Exception as ex:
139+
raise ArkException(f'Failed to ssh connect [{str(ex)}]') from ex
140+
141+
@overrides
142+
def disconnect(self) -> None:
143+
"""
144+
Disconnects the ssh session
145+
"""
146+
if self.__pty:
147+
self.__pty.write('exit\n')
148+
self.__pty = None
149+
self.__is_connected = False
150+
self.__is_suspended = False
151+
152+
@overrides
153+
def suspend_connection(self) -> None:
154+
"""
155+
Suspends execution of ssh commands
156+
"""
157+
self.__is_suspended = True
158+
159+
@overrides
160+
def restore_connection(self) -> None:
161+
"""
162+
Restores execution of ssh commands
163+
"""
164+
self.__is_suspended = False
165+
166+
@overrides
167+
def is_suspended(self) -> bool:
168+
"""
169+
Checks whether ssh commands can be executed or not
170+
171+
Returns:
172+
bool: _description_
173+
"""
174+
return self.__is_suspended
175+
176+
@overrides
177+
def is_connected(self) -> bool:
178+
"""
179+
Checks whether theres a ssh session connected
180+
181+
Returns:
182+
bool: _description_
183+
"""
184+
return self.__is_connected
185+
186+
@overrides
187+
def run_command(self, command: ArkConnectionCommand) -> ArkConnectionResult:
188+
"""
189+
Runs a command over ssh session via pty, returning the result accordingly
190+
stderr is not supported, only stdout is returned, and it'll contain everything including stderr
191+
192+
Args:
193+
command (ArkConnectionCommand): _description_
194+
195+
Raises:
196+
ArkException: _description_
197+
198+
Returns:
199+
ArkConnectionResult: _description_
200+
"""
201+
if not self.__is_connected or self.__is_suspended:
202+
raise ArkException('Cannot run command while not connected or suspended')
203+
204+
self._logger.debug(f'Running command [{command.command}]')
205+
206+
self.__reset_buffer()
207+
self.__pty.write(command.command + "\n")
208+
self.__read_until_latest_prompt()
209+
210+
with self.__output_lock:
211+
stdout = self.__buffer.strip()
212+
213+
self.__reset_buffer()
214+
self.__pty.write(f'echo {ArkPTYSSHWinConnection.__EXIT_MAKRER}_$?;\n')
215+
self.__read_until_latest_prompt()
216+
217+
with self.__output_lock:
218+
lines = self.__buffer.strip().splitlines()
219+
220+
rc = None
221+
for line in lines:
222+
line = self.__strip_ansi(line)
223+
if line.startswith(f'{ArkPTYSSHWinConnection.__EXIT_MAKRER}_'):
224+
try:
225+
rc = int(line[len(ArkPTYSSHWinConnection.__EXIT_MAKRER) + 1 :]) # +1 for the underscore
226+
break
227+
except ValueError:
228+
continue
229+
if rc is None:
230+
raise ArkException(f"Failed to parse exit code from output - [{self.__buffer}]")
231+
if rc != command.expected_rc and command.raise_on_error:
232+
raise ArkException(f'Failed to execute command [{command.command}] - [{rc}] - [{stdout}]')
233+
self._logger.debug(f'Command rc: [{rc}]')
234+
self._logger.debug(f'Command stdout: [{stdout}]')
235+
return ArkConnectionResult(stdout=stdout, rc=rc)

ark_sdk_python/examples/validate_ssh_connection.py

+31-14
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,54 @@
11
import argparse
2+
import platform
23
import tempfile
34

45
from ark_sdk_python.auth import ArkISPAuth
56
from ark_sdk_python.common.ark_jwt_utils import ArkJWTUtils
67
from ark_sdk_python.common.connections.ssh.ark_pty_ssh_connection import ArkPTYSSHConnection
8+
from ark_sdk_python.common.connections.ssh.ark_pty_ssh_win_connection import ArkPTYSSHWinConnection
79
from ark_sdk_python.models.auth import ArkAuthMethod, ArkAuthProfile, ArkSecret
8-
from ark_sdk_python.models.auth.ark_auth_method import IdentityServiceUserArkAuthMethodSettings
10+
from ark_sdk_python.models.auth.ark_auth_method import (
11+
ArkAuthMethodsDescriptionMap,
12+
IdentityArkAuthMethodSettings,
13+
IdentityServiceUserArkAuthMethodSettings,
14+
)
915
from ark_sdk_python.models.common.connections.ark_connection_command import ArkConnectionCommand
1016
from ark_sdk_python.models.common.connections.ark_connection_credentials import ArkConnectionCredentials
1117
from ark_sdk_python.models.common.connections.ark_connection_details import ArkConnectionDetails
1218
from ark_sdk_python.models.services.sia.sso.ark_sia_sso_get_ssh_key import ArkSIASSOGetSSHKey
1319
from ark_sdk_python.services.sia.sso import ArkSIASSOService
1420

1521

16-
def login_to_identity_security_platform(service_user: str, service_token: str, application_name: str) -> ArkISPAuth:
22+
def login_to_identity_security_platform(user: str, secret: str, is_service_user: bool, application_name: str) -> ArkISPAuth:
1723
"""
18-
This will perform login to the tenant with the given service user credentials
24+
This will perform login to the tenant with the given user credentials
25+
Where the user is normal or service user based on the boolean
1926
Caching the logged in token is disabled and will perform a full login on each call
2027
Will return an identity security platform authenticated class
2128
2229
Args:
23-
service_user (str): _description_
24-
service_token (str): _description_
30+
user (str): _description_
31+
secret (str): _description_
2532
application_name (str): _description_
2633
2734
Returns:
2835
ArkISPAuth: _description_
2936
"""
30-
print('Logging in to the tenant')
3137
isp_auth = ArkISPAuth(cache_authentication=False)
38+
auth_method = ArkAuthMethod.IdentityServiceUser if is_service_user else ArkAuthMethod.Identity
39+
auth_methods_settings = (
40+
IdentityServiceUserArkAuthMethodSettings(identity_authorization_application=application_name)
41+
if is_service_user
42+
else IdentityArkAuthMethodSettings()
43+
)
44+
print(f'Logging in to the tenant with [{ArkAuthMethodsDescriptionMap[auth_method]}] user type and user [{user}]')
3245
isp_auth.authenticate(
3346
auth_profile=ArkAuthProfile(
34-
username=service_user,
35-
auth_method=ArkAuthMethod.IdentityServiceUser,
36-
auth_method_settings=IdentityServiceUserArkAuthMethodSettings(identity_authorization_application=application_name),
47+
username=user,
48+
auth_method=auth_method,
49+
auth_method_settings=auth_methods_settings,
3750
),
38-
secret=ArkSecret(secret=service_token),
51+
secret=ArkSecret(secret=secret),
3952
)
4053
print('Logged in successfully')
4154
return isp_auth
@@ -92,7 +105,10 @@ def connect_and_validate_connection(ssh_key_path: str, proxy_address: str, conne
92105
command (str): _description_
93106
"""
94107
print(f'Connecting to proxy [{proxy_address}] and connection string [{connection_string}]')
95-
ssh_connection = ArkPTYSSHConnection()
108+
if platform.system() == 'Windows':
109+
ssh_connection = ArkPTYSSHWinConnection()
110+
else:
111+
ssh_connection = ArkPTYSSHConnection()
96112
ssh_connection.connect(
97113
ArkConnectionDetails(
98114
address=proxy_address,
@@ -112,8 +128,9 @@ def connect_and_validate_connection(ssh_key_path: str, proxy_address: str, conne
112128
if __name__ == '__main__':
113129
# Construct an argument parser for CLI parameters for the script
114130
parser = argparse.ArgumentParser()
115-
parser.add_argument('--service-user', required=True, help='Service user to login and perform the operation with')
116-
parser.add_argument('--service-token', required=True, help='Service user token to use for logging in and connecting')
131+
parser.add_argument('--user', required=True, help='User to login and perform the operation with')
132+
parser.add_argument('--secret', required=True, help='User secret to use for logging in and connecting')
133+
parser.add_argument('--is-service-user', action='store_true', help='Whether this user is a service user and not a normal user')
117134
parser.add_argument(
118135
'--service-application',
119136
default='__idaptive_cybr_user_oidc',
@@ -139,7 +156,7 @@ def connect_and_validate_connection(ssh_key_path: str, proxy_address: str, conne
139156
# - Generate an MFA Caching SSH key
140157
# - Construct the ssh proxy address
141158
# - Connect in SSH to the proxy / target with the MFA Caching SSH Key and perform the command
142-
isp_auth = login_to_identity_security_platform(args.service_user, args.service_token, args.service_application)
159+
isp_auth = login_to_identity_security_platform(args.user, args.secret, args.is_service_user, args.service_application)
143160
ssh_key_path = generate_mfa_caching_ssh_key(isp_auth, temp_folder)
144161
proxy_address = construct_ssh_proxy_address(isp_auth)
145162
connect_and_validate_connection(ssh_key_path, proxy_address, args.connection_string, args.test_command)

0 commit comments

Comments
 (0)