Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SSH]Install Arc SSH Proxy from MAR instead of Storage Blob #7726

Merged
merged 7 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading