Skip to content

Commit

Permalink
[SSH] Add support for ephemeral keypair generation and public key sig…
Browse files Browse the repository at this point in the history
…ning only operation (Azure#2516)

* Initial changes

* Update tests and correct some issues

* Should be an empty string

* Correct path and help

* Fix spacing
  • Loading branch information
N6UDP authored Oct 14, 2020
1 parent 8e5cba9 commit 2b17c25
Show file tree
Hide file tree
Showing 10 changed files with 135 additions and 94 deletions.
8 changes: 8 additions & 0 deletions src/ssh/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release History
===============

0.1.2
-----
* Add support for hardware tokens (don't require the private key be passed in)
* Add support for cert signing only
* Add numerous short parameters
* Add support for and switch default behavior to ephemeral keypair generation when no public key is passed in
* Add support for Host * by not writing Hostname into ssh_config files

0.1.1
-----
* Fix bash not work problem.
Expand Down
25 changes: 20 additions & 5 deletions src/ssh/azext_ssh/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,44 @@

helps['ssh'] = """
type: group
short-summary: SSH into Azure VMs
short-summary: SSH into resources (Azure VMs, etc) using AAD issued openssh certificates
"""

helps['ssh vm'] = """
type: command
short-summary: SSH into Azure VMs
short-summary: SSH into Azure VMs using an ssh certificate
examples:
- name: Give a resource group and VM to SSH to
text: |
az ssh vm --resource-group myResourceGroup --vm-name myVm
- name: Give the public IP of a VM to SSH to
- name: Give the public IP (or hostname) of a VM to SSH to
text: |
az ssh vm --ip 1.2.3.4
"""

helps['ssh config'] = """
type: command
short-summary: Create an SSH config for Azure VMs which can then be imported to 3rd party SSH clients
short-summary: Create an SSH config for resources (Azure VMs, etc) which can then be imported to 3rd party SSH clients
examples:
- name: Give a resource group and VM for which to create a config, and save in a local file
text: |
az ssh config --resource-group myResourceGroup --vm-name myVm --file ./sshconfig
- name: Give the public IP of a VM for which to create a config
- name: Give the public IP (or hostname) of a VM for which to create a config
text: |
az ssh config --ip 1.2.3.4 --file ./sshconfig
- name: Create a generic config for use with any host
text: |
#Bash
az ssh config --ip \\* --file ./sshconfig
#PowerShell
az ssh config --ip * --file ./sshconfig
"""

helps['ssh cert'] = """
type: command
short-summary: Create an SSH RSA certifcate signed by AAD
examples:
- name: Create a short lived ssh certificate signed by AAD
text: |
az ssh cert --public-key-file ./id_rsa.pub --file ./id_rsa-aadcert.pub
"""
23 changes: 14 additions & 9 deletions src/ssh/azext_ssh/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@
def load_arguments(self, _):

with self.argument_context('ssh vm') as c:
c.argument('vm_name', options_list=['--vm-name'], help='The name of the VM')
c.argument('ssh_ip', options_list=['--ip'], help='The public IP address of the VM')
c.argument('public_key_file', help='The RSA public key file path')
c.argument('private_key_file', help='The RSA private key file path')
c.argument('vm_name', options_list=['--vm-name', '-n'], help='The name of the VM')
c.argument('ssh_ip', options_list=['--ip'], help='The public IP address (or hostname) of the VM')
c.argument('public_key_file', options_list=['--public-key-file', '-p'], help='The RSA public key file path')
c.argument('private_key_file', options_list=['--private-key-file', '-i'], help='The RSA private key file path')

with self.argument_context('ssh config') as c:
c.argument('config_path', options_list=['--file'], help='The file path to write the SSH config to')
c.argument('vm_name', options_list=['--vm-name'], help='The name of the VM')
c.argument('ssh_ip', options_list=['--ip'], help='The public IP address of the VM')
c.argument('public_key_file', help='The RSA public key file path')
c.argument('private_key_file', help='The RSA private key file path')
c.argument('config_path', options_list=['--file', '-f'], help='The file path to write the SSH config to')
c.argument('vm_name', options_list=['--vm-name', '-n'], help='The name of the VM')
c.argument('ssh_ip', options_list=['--ip'], help='The public IP address (or hostname) of the VM')
c.argument('public_key_file', options_list=['--public-key-file', '-p'], help='The RSA public key file path')
c.argument('private_key_file', options_list=['--private-key-file', '-i'], help='The RSA private key file path')

with self.argument_context('ssh cert') as c:
c.argument('cert_path', options_list=['--file', '-f'],
help='The file path to write the SSH cert to, defaults to public key path with -aadcert.pub appened')
c.argument('public_key_file', options_list=['--public-key-file', '-p'], help='The RSA public key file path')
1 change: 1 addition & 0 deletions src/ssh/azext_ssh/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ def load_command_table(self, _):
with self.command_group('ssh') as g:
g.custom_command('vm', 'ssh_vm')
g.custom_command('config', 'ssh_config')
g.custom_command('cert', 'ssh_cert')
44 changes: 31 additions & 13 deletions src/ssh/azext_ssh/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import hashlib
import json
import tempfile

from knack import util

Expand All @@ -26,22 +27,33 @@ def ssh_config(cmd, config_path, resource_group_name=None, vm_name=None, ssh_ip=
_do_ssh_op(cmd, resource_group_name, vm_name, ssh_ip, public_key_file, private_key_file, op_call)


def ssh_cert(cmd, cert_path=None, public_key_file=None):
public_key_file, _ = _check_or_create_public_private_files(public_key_file, None)
cert_file, _ = _get_and_write_certificate(cmd, public_key_file, cert_path)
print(cert_file + "\n")


def _do_ssh_op(cmd, resource_group, vm_name, ssh_ip, public_key_file, private_key_file, op_call):
_assert_args(resource_group, vm_name, ssh_ip)
public_key_file, private_key_file = _check_public_private_files(public_key_file, private_key_file)
public_key_file, private_key_file = _check_or_create_public_private_files(public_key_file, private_key_file)
ssh_ip = ssh_ip or ip_utils.get_ssh_ip(cmd, resource_group, vm_name)

if not ssh_ip:
raise util.CLIError(f"VM '{vm_name}' does not have a public IP address to SSH to")

cert_file, username = _get_and_write_certificate(cmd, public_key_file, None)
op_call(ssh_ip, username, cert_file, private_key_file)


def _get_and_write_certificate(cmd, public_key_file, cert_file):
scopes = ["https://pas.windows.net/CheckMyAccess/Linux/user_impersonation"]
data = _prepare_jwk_data(public_key_file)
from azure.cli.core._profile import Profile
profile = Profile(cli_ctx=cmd.cli_ctx)
username, certificate = profile.get_msal_token(scopes, data)

cert_file = _write_cert_file(public_key_file, certificate)
op_call(ssh_ip, username, cert_file, private_key_file)
if not cert_file:
cert_file = public_key_file + "-aadcert.pub"
return _write_cert_file(certificate, cert_file), username


def _prepare_jwk_data(public_key_file):
Expand Down Expand Up @@ -76,21 +88,27 @@ def _assert_args(resource_group, vm_name, ssh_ip):
raise util.CLIError("--ip cannot be used with --resource-group or --name")


def _check_public_private_files(public_key_file, private_key_file):
ssh_dir_parts = ["~", ".ssh"]
public_key_file = public_key_file or os.path.expanduser(os.path.join(*ssh_dir_parts, "id_rsa.pub"))
private_key_file = private_key_file or os.path.expanduser(os.path.join(*ssh_dir_parts, "id_rsa"))
def _check_or_create_public_private_files(public_key_file, private_key_file):
# If nothing is passed in create a temporary directory with a ephemeral keypair
if not public_key_file and not private_key_file:
temp_dir = tempfile.mkdtemp(prefix="aadsshcert")
public_key_file = os.path.join(temp_dir, "id_rsa.pub")
private_key_file = os.path.join(temp_dir, "id_rsa")
ssh_utils.create_ssh_keyfile(private_key_file)

if not os.path.isfile(public_key_file):
raise util.CLIError(f"Pulic key file {public_key_file} not found")
if not os.path.isfile(private_key_file):
raise util.CLIError(f"Private key file {private_key_file} not found")
raise util.CLIError(f"Public key file {public_key_file} not found")

# The private key is not required as the user may be using a keypair
# stored in ssh-agent (and possibly in a hardware token)
if private_key_file:
if not os.path.isfile(private_key_file):
raise util.CLIError(f"Private key file {private_key_file} not found")

return public_key_file, private_key_file


def _write_cert_file(public_key_file, certificate_contents):
cert_file = os.path.join(*os.path.split(public_key_file)[:-1], "id_rsa-cert.pub")
def _write_cert_file(certificate_contents, cert_file):
with open(cert_file, 'w') as f:
f.write(f"ssh-rsa-cert-v01@openssh.com {certificate_contents}")

Expand Down
29 changes: 21 additions & 8 deletions src/ssh/azext_ssh/ssh_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ def start_ssh_connection(ip, username, cert_file, private_key_file):
subprocess.call(command, shell=platform.system() == 'Windows')


def create_ssh_keyfile(private_key_file):
command = [_get_ssh_path("ssh-keygen"), "-f", private_key_file, "-t", "rsa", "-q", "-N", ""]
logger.debug("Running ssh-keygen command %s", ' '.join(command))
subprocess.call(command, shell=platform.system() == 'Windows')


def write_ssh_config(config_path, resource_group, vm_name,
ip, username, cert_file, private_key_file):
file_utils.make_dirs_for_file(config_path)
Expand All @@ -30,34 +36,39 @@ def write_ssh_config(config_path, resource_group, vm_name,
lines.append("\tUser " + username)
lines.append("\tHostName " + ip)
lines.append("\tCertificateFile " + cert_file)
lines.append("\tIdentityFile " + private_key_file)
if private_key_file:
lines.append("\tIdentityFile " + private_key_file)

# default to all hosts for config
if not ip:
ip = "*"

lines.append("Host " + ip)
lines.append("\tUser " + username)
lines.append("\tHostName " + ip)
lines.append("\tCertificateFile " + cert_file)
lines.append("\tIdentityFile " + private_key_file)
if private_key_file:
lines.append("\tIdentityFile " + private_key_file)

with open(config_path, 'w') as f:
f.write('\n'.join(lines))


def _get_ssh_path():
ssh_path = "ssh"
def _get_ssh_path(ssh_command="ssh"):
ssh_path = ssh_command

if platform.system() == 'Windows':
arch_data = platform.architecture()
is_32bit = arch_data[0] == '32bit'
sys_path = 'SysNative' if is_32bit else 'System32'
system_root = os.environ['SystemRoot']
system32_path = os.path.join(system_root, sys_path)
ssh_path = os.path.join(system32_path, "openSSH", "ssh.exe")
ssh_path = os.path.join(system32_path, "openSSH", (ssh_command + ".exe"))
logger.debug("Platform architecture: %s", str(arch_data))
logger.debug("System Root: %s", system_root)
logger.debug("Attempting to run ssh from path %s", ssh_path)

if not os.path.isfile(ssh_path):
raise util.CLIError("Could not find ssh.exe. Is the OpenSSH client installed?")
raise util.CLIError("Could not find " + ssh_command + ".exe. Is the OpenSSH client installed?")

return ssh_path

Expand All @@ -67,6 +78,8 @@ def _get_host(username, ip):


def _build_args(cert_file, private_key_file):
private_key = ["-i", private_key_file]
private_key = []
if private_key_file:
private_key = ["-i", private_key_file]
certificate = ["-o", "CertificateFile=" + cert_file]
return private_key + certificate
Loading

0 comments on commit 2b17c25

Please sign in to comment.