Skip to content

Commit 9eef3d5

Browse files
committed
Win PTY implementation for ssh connections in windows
1 parent 0817fe1 commit 9eef3d5

File tree

4 files changed

+470
-206
lines changed

4 files changed

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