Skip to content

Commit

Permalink
[SSH]Install Arc SSH Proxy from MAR instead of Storage Blob (#7726)
Browse files Browse the repository at this point in the history
  • Loading branch information
vthiebaut10 committed Jun 28, 2024
1 parent 81ca703 commit 695bd02
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 131 deletions.
4 changes: 4 additions & 0 deletions src/ssh/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
Release History
===============
2.0.4
-----
* Install Arc SSH Proxy from MAR

2.0.3
-----
* [Bug Fix] Ensure that certificate validity value is always an integer when retrieving relay information for connecting to Arc Machines.
Expand Down
305 changes: 182 additions & 123 deletions src/ssh/azext_ssh/connectivity_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
# --------------------------------------------------------------------------------------------

import time
import stat
import os
import urllib.request
import json
import base64
import oras.client
import tarfile
from glob import glob

import colorama
Expand Down Expand Up @@ -185,160 +185,219 @@ def _list_credentials(cmd, resource_uri, certificate_validity_in_seconds):
return ListCredential(cli_ctx=cmd.cli_ctx)(command_args=list_cred_args)


def format_relay_info_string(relay_info):
relay_info_string = json.dumps(
{
"relay": {
"namespaceName": relay_info['namespaceName'],
"namespaceNameSuffix": relay_info['namespaceNameSuffix'],
"hybridConnectionName": relay_info['hybridConnectionName'],
"accessKey": relay_info['accessKey'],
"expiresOn": relay_info['expiresOn'],
"serviceConfigurationToken": relay_info['serviceConfigurationToken']
}
})
result_bytes = relay_info_string.encode("ascii")
enc = base64.b64encode(result_bytes)
base64_result_string = enc.decode("ascii")
return base64_result_string


def _handle_relay_connection_delay(cmd, message):
# relay has retry delay after relay connection is lost
# must sleep for at least as long as the delay
# otherwise the ssh connection will fail
progress_bar = cmd.cli_ctx.get_progress_controller(True)
for x in range(0, consts.SERVICE_CONNECTION_DELAY_IN_SECONDS + 1):
interval = float(1 / consts.SERVICE_CONNECTION_DELAY_IN_SECONDS)
progress_bar.add(message=f"{message}:",
value=interval * x, total_val=1.0)
time.sleep(1)
progress_bar.add(message=f"{message}: complete",
value=1.0, total_val=1.0)
progress_bar.end()


# Downloads client side proxy to connect to Arc Connectivity Platform
def get_client_side_proxy(arc_proxy_folder):
def install_client_side_proxy(arc_proxy_folder):

request_uri, install_location, older_version_location = _get_proxy_filename_and_url(arc_proxy_folder)
install_dir = os.path.dirname(install_location)
client_operating_system = _get_client_operating_system()
client_architecture = _get_client_architeture()
install_dir = _get_proxy_install_dir(arc_proxy_folder)
proxy_name = _get_proxy_filename(client_operating_system, client_architecture)
install_location = os.path.join(install_dir, proxy_name)

# Only download new proxy if it doesn't exist already
if not os.path.isfile(install_location):
t0 = time.time()
# download the executable
try:
with urllib.request.urlopen(request_uri) as response:
response_content = response.read()
response.close()
except Exception as e:
raise azclierror.ClientRequestError(f"Failed to download client proxy executable from {request_uri}. "
"Error: " + str(e)) from e
time_elapsed = time.time() - t0

proxy_data = {
'Context.Default.AzureCLI.SSHProxyDownloadTime': time_elapsed,
'Context.Default.AzureCLI.SSHProxyVersion': consts.CLIENT_PROXY_VERSION
}
telemetry.add_extension_event('ssh', proxy_data)

# if directory doesn't exist, create it
if not os.path.isdir(install_dir):
file_utils.create_directory(install_dir, f"Failed to create client proxy directory '{install_dir}'. ")
file_utils.create_directory(install_dir, f"Failed to create client proxy directory '{install_dir}'.")
# if directory exists, delete any older versions of the proxy
else:
older_version_location = _get_older_version_proxy_path(
install_dir,
client_operating_system,
client_architecture)
older_version_files = glob(older_version_location)
for f in older_version_files:
file_utils.delete_file(f, f"failed to delete older version file {f}", warning=True)

# write executable in the install location
file_utils.write_to_file(install_location, 'wb', response_content, "Failed to create client proxy file. ")
os.chmod(install_location, os.stat(install_location).st_mode | stat.S_IXUSR)
print_styled_text((Style.SUCCESS, f"SSH Client Proxy saved to {install_location}"))

_download_proxy_license(arc_proxy_folder)
_download_proxy_from_MCR(install_dir, proxy_name, client_operating_system, client_architecture)
_check_proxy_installation(install_dir, proxy_name)

return install_location


def _get_proxy_filename_and_url(arc_proxy_folder):
import platform
operating_system = platform.system()
machine = platform.machine()
def _download_proxy_from_MCR(dest_dir, proxy_name, operating_system, architecture):
mar_target = f"{consts.CLIENT_PROXY_MCR_TARGET}/{operating_system.lower()}/{architecture}/ssh-proxy"
logger.debug("Downloading Arc Connectivity Proxy from %s in Microsoft Artifact Regristy.", mar_target)

logger.debug("Platform OS: %s", operating_system)
logger.debug("Platform architecture: %s", machine)
client = oras.client.OrasClient()
t0 = time.time()

if "arm64" in machine.lower() or "aarch64" in machine.lower():
architecture = 'arm64'
elif machine.endswith('64'):
architecture = 'amd64'
elif machine.endswith('86'):
architecture = '386'
elif machine == '':
raise azclierror.BadRequestError("Couldn't identify the platform architecture.")
else:
raise azclierror.BadRequestError(f"Unsuported architecture: {machine} is not currently supported")
try:
response = client.pull(target=f"{mar_target}:{consts.CLIENT_PROXY_VERSION}", outdir=dest_dir)
except Exception as e:
raise azclierror.CLIInternalError(
f"Failed to download Arc Connectivity proxy with error {str(e)}. Please try again.")

# define the request url and install location based on the os and architecture.
proxy_name = f"sshProxy_{operating_system.lower()}_{architecture}"
request_uri = (f"{consts.CLIENT_PROXY_STORAGE_URL}/{consts.CLIENT_PROXY_RELEASE}"
f"/{proxy_name}_{consts.CLIENT_PROXY_VERSION}")
install_location = proxy_name + "_" + consts.CLIENT_PROXY_VERSION.replace('.', '_')
older_location = proxy_name + "*"

if operating_system == 'Windows':
request_uri = request_uri + ".exe"
install_location = install_location + ".exe"
older_location = older_location + ".exe"
elif operating_system not in ('Linux', 'Darwin'):
raise azclierror.BadRequestError(f"Unsuported OS: {operating_system} platform is not currently supported")
time_elapsed = time.time() - t0

if not arc_proxy_folder:
install_location = os.path.expanduser(os.path.join('~', os.path.join(".clientsshproxy", install_location)))
older_location = os.path.expanduser(os.path.join('~', os.path.join(".clientsshproxy", older_location)))
else:
install_location = os.path.join(arc_proxy_folder, install_location)
older_location = os.path.join(arc_proxy_folder, older_location)
proxy_data = {
'Context.Default.AzureCLI.SSHProxyDownloadTime': time_elapsed,
'Context.Default.AzureCLI.SSHProxyVersion': consts.CLIENT_PROXY_VERSION
}
telemetry.add_extension_event('ssh', proxy_data)

return request_uri, install_location, older_location
proxy_package_path = _get_proxy_package_path_from_oras_response(response)
_extract_proxy_tar_files(proxy_package_path, dest_dir, proxy_name)
file_utils.delete_file(proxy_package_path, f"Failed to delete {proxy_package_path}. Please delete manually.", True)


def _download_proxy_license(proxy_dir):
if not proxy_dir:
proxy_dir = os.path.join('~', ".clientsshproxy")
license_uri = f"{consts.CLIENT_PROXY_STORAGE_URL}/{consts.CLIENT_PROXY_RELEASE}/LICENSE.txt"
license_install_location = os.path.expanduser(os.path.join(proxy_dir, "LICENSE.txt"))
def _get_proxy_package_path_from_oras_response(pull_response):
if not isinstance(pull_response, list):
raise azclierror.CLIInternalError(
"Attempt to download Arc Connectivity Proxy returned unnexpected result. Please try again.")

notice_uri = f"{consts.CLIENT_PROXY_STORAGE_URL}/{consts.CLIENT_PROXY_RELEASE}/ThirdPartyNotice.txt"
notice_install_location = os.path.expanduser(os.path.join(proxy_dir, "ThirdPartyNotice.txt"))
if len(pull_response) != 1:
for r in pull_response:
file_utils.delete_file(r, f"Failed to delete {r}. Please delete it manually.", True)
raise azclierror.CLIInternalError(
"Attempt to download Arc Connectivity Proxy returned unnexpected result. Please try again.")

_get_and_write_proxy_license_files(license_uri, license_install_location, "License")
_get_and_write_proxy_license_files(notice_uri, notice_install_location, "Third Party Notice")
proxy_package_path = pull_response[0]

if not os.path.isfile(proxy_package_path):
raise azclierror.CLIInternalError("Unable to download Arc Connectivity Proxy. Please try again.")

def _get_and_write_proxy_license_files(uri, install_location, target_name):
try:
license_content = _download_from_uri(uri)
file_utils.write_to_file(file_path=install_location,
mode='wb',
content=license_content,
error_message=f"Failed to create {target_name} file at {install_location}.")
# pylint: disable=broad-except
except Exception:
logger.warning("Failed to download Connection Proxy %s file from %s.", target_name, uri)
logger.debug("Proxy package downloaded to %s", proxy_package_path)

print_styled_text((Style.SUCCESS, f"SSH Connection Proxy {target_name} saved to {install_location}."))
return proxy_package_path


def _download_from_uri(request_uri):
response_content = None
with urllib.request.urlopen(request_uri) as response:
response_content = response.read()
response.close()
def _extract_proxy_tar_files(proxy_package_path, install_dir, proxy_name):
with tarfile.open(proxy_package_path, 'r:gz') as tar:
members = []
for member in tar.getmembers():
if member.isfile():
filenames = member.name.split('/')

if response_content is None:
raise azclierror.ClientRequestError(f"Failed to download file from {request_uri}")
if len(filenames) != 2:
tar.close()
file_utils.delete_file(
proxy_package_path,
f"Failed to delete {proxy_package_path}. Please delete it manually.",
True)
raise azclierror.CLIInternalError(
"Attempt to download Arc Connectivity Proxy returned unnexpected result. Please try again.")

return response_content
member.name = filenames[1]

if member.name.startswith('sshproxy'):
member.name = proxy_name
elif member.name.lower() not in ['license.txt', 'thirdpartynotice.txt']:
tar.close()
file_utils.delete_file(
proxy_package_path,
f"Failed to delete {proxy_package_path}. Please delete it manually.",
True)
raise azclierror.CLIInternalError(
"Attempt to download Arc Connectivity Proxy returned unnexpected result. Please try again.")

def format_relay_info_string(relay_info):
relay_info_string = json.dumps(
{
"relay": {
"namespaceName": relay_info['namespaceName'],
"namespaceNameSuffix": relay_info['namespaceNameSuffix'],
"hybridConnectionName": relay_info['hybridConnectionName'],
"accessKey": relay_info['accessKey'],
"expiresOn": relay_info['expiresOn'],
"serviceConfigurationToken": relay_info['serviceConfigurationToken']
}
})
result_bytes = relay_info_string.encode("ascii")
enc = base64.b64encode(result_bytes)
base64_result_string = enc.decode("ascii")
return base64_result_string
members.append(member)

tar.extractall(members=members, path=install_dir)

def _handle_relay_connection_delay(cmd, message):
# relay has retry delay after relay connection is lost
# must sleep for at least as long as the delay
# otherwise the ssh connection will fail
progress_bar = cmd.cli_ctx.get_progress_controller(True)
for x in range(0, consts.SERVICE_CONNECTION_DELAY_IN_SECONDS + 1):
interval = float(1 / consts.SERVICE_CONNECTION_DELAY_IN_SECONDS)
progress_bar.add(message=f"{message}:",
value=interval * x, total_val=1.0)
time.sleep(1)
progress_bar.add(message=f"{message}: complete",
value=1.0, total_val=1.0)
progress_bar.end()

def _check_proxy_installation(install_dir, proxy_name):
proxy_filepath = os.path.join(install_dir, proxy_name)
if os.path.isfile(proxy_filepath):
print_styled_text((Style.SUCCESS, f"Successfuly installed SSH Connectivity Proxy file {proxy_filepath}"))
else:
raise azclierror.CLIInternalError(
"Failed to install required SSH Arc Connectivity Proxy. "
f"Couldn't find expected file {proxy_filepath}. Please try again.")

license_files = ["LICENSE.txt", "ThirdPartyNotice.txt"]
for file in license_files:
file_location = os.path.join(install_dir, file)
if os.path.isfile(file_location):
print_styled_text(
(Style.SUCCESS,
f"Successfuly installed SSH Connectivity Proxy License file {file_location}"))
else:
logger.warning(
"Failed to download Arc Connectivity Proxy license file %s. Clouldn't find expected file %s. "
"This won't affect your connection.", file, file_location)


def _get_proxy_filename(operating_system, architecture):
if operating_system.lower() == 'darwin' and architecture == '386':
raise azclierror.BadRequestError("Unsupported Darwin OS with 386 architecture.")
proxy_filename = \
f"sshProxy_{operating_system.lower()}_{architecture}_{consts.CLIENT_PROXY_VERSION.replace('.', '_')}"
if operating_system.lower() == 'windows':
proxy_filename += '.exe'
return proxy_filename


def _get_older_version_proxy_path(install_dir, operating_system, architecture):
proxy_name = f"sshProxy_{operating_system.lower()}_{architecture}_*"
return os.path.join(install_dir, proxy_name)


def _get_proxy_install_dir(arc_proxy_folder):
if not arc_proxy_folder:
return os.path.expanduser(os.path.join('~', ".clientsshproxy"))
return arc_proxy_folder


def _get_client_architeture():
import platform
machine = platform.machine()
architecture = None

logger.debug("Platform architecture: %s", machine)

if "arm64" in machine.lower() or "aarch64" in machine.lower():
architecture = 'arm64'
elif machine.endswith('64'):
architecture = 'amd64'
elif machine.endswith('86'):
architecture = '386'
elif machine == '':
raise azclierror.BadRequestError("Couldn't identify the platform architecture.")
else:
raise azclierror.BadRequestError(f"Unsuported architecture: {machine} is not currently supported")

return architecture


def _get_client_operating_system():
import platform
operating_system = platform.system()

logger.debug("Platform OS: %s", operating_system)

if operating_system.lower() not in ('linux', 'darwin', 'windows'):
raise azclierror.BadRequestError(f"Unsuported OS: {operating_system} platform is not currently supported")
return operating_system
5 changes: 2 additions & 3 deletions src/ssh/azext_ssh/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@

AGENT_MINIMUM_VERSION_MAJOR = 1
AGENT_MINIMUM_VERSION_MINOR = 31
CLIENT_PROXY_VERSION = "1.3.026031"
CLIENT_PROXY_RELEASE = "release17-02-24"
CLIENT_PROXY_STORAGE_URL = "https://sshproxysa.blob.core.windows.net"
CLIENT_PROXY_VERSION = "1.3.026973"
CLIENT_PROXY_MCR_TARGET = "mcr.microsoft.com/azureconnectivity/proxy"
CLEANUP_TOTAL_TIME_LIMIT_IN_SECONDS = 120
CLEANUP_TIME_INTERVAL_IN_SECONDS = 10
CLEANUP_AWAIT_TERMINATION_IN_SECONDS = 30
Expand Down
2 changes: 1 addition & 1 deletion src/ssh/azext_ssh/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def _do_ssh_op(cmd, op_info, op_call):

try:
if op_info.is_arc():
op_info.proxy_path = connectivity_utils.get_client_side_proxy(op_info.ssh_proxy_folder)
op_info.proxy_path = connectivity_utils.install_client_side_proxy(op_info.ssh_proxy_folder)
(op_info.relay_info, op_info.new_service_config) = connectivity_utils.get_relay_information(
cmd, op_info.resource_group_name, op_info.vm_name, op_info.resource_type,
cert_lifetime, op_info.port, op_info.yes_without_prompt)
Expand Down
Loading

0 comments on commit 695bd02

Please sign in to comment.