sshansibleazure-cliazure-linux

Use `az ssh vm` (AADSSHLoginForLinux) with Ansible


I have deployed an Azure Linux VM and installed the AADSSHLoginForLinux VM extension. This allows me to login to the VM using my Azure credentials, and allows me to configure role based access for my team using EntraID (Active Directory) groups. Any team member can log into the VM using the following command, without needing to mess with SSH keys, etc, and they will get the appropriate access (sudo or regular user) depending on what EntraID group they belong to, using the azure-cli ssh wrapper:

az ssh vm --ip 1.2.3.4

(Assuming they have previously run as login at some point.)

Now I want to use this as the ssh command for Ansible, which will automatically allow anyone in the admin security group to deploy the playbook.

az ssh vm will pass through ssh arguments supplied following --. For example:

az ssh vm --ip 1.2.3.4 -- -p 23

So it should be possible to wrap az ssh vm up in such a way that Ansible can use it.

I have found the ssh_executable option for Ansible, but this expects a command, not a command with arguments, so I can't simply set it to az ssh vm .... So a wrapper script will be necessary.

I have also determined that Microsoft do not supply an equivalent wrapper for scp, so this will need to be worked around.

How can I put all the pieces together to easily deploy Ansible playbooks using my Azure credentials and az ssh?


Solution

  • Edit: PyPI release

    I've slightly adapted the Python wrapper below and published it on PyPI as az-ssh-wrapper. After installing, it provides the SSH wrapper command az-ssh, which can be used exactly like regular SSH, but will use az ssh vm behind the scenes.

    TL;DR:

    See original answer below for details about using this with Ansible.

    Original answer

    The first step is to create an SSH wrapper which will intercept the SSH arguments from Ansible and pass them through to az ssh.

    This blog post describes how to do this for GCP IAP. I have adapted it to work with az ssh:

    I am using the following wrapper, adapted from the blog post above:

    #!/bin/bash
    # ssh-wrapper.sh
    #
    # SSH Wrapper for az ssh
    #
    # We are using `az ssh vm --ip 1.2.3.4` to authenticate to VMs with
    # Azure Role Based Athentication. However, Ansible doesn't directly
    # support this.
    #
    # `az ssh vm` accepts additional SSH arguments, so this wrapper takes
    # arguments from Ansible, and passes them onto `az ssh vm`.
    
    # Wrapper adapted from here:
    # https://blg.robot-house.us/posts/ansible-and-iap/
    
    # Get last two arguments
    host="${*: -2: 1}"
    cmd="${*: -1: 1}"
    
    # Filter out hard-coded Ansible SSH options.
    # At least one of these seems to break az ssh, but I haven't figured out which.
    # But it seems to work if you filter them all out as described in the blog linked above.
    # This may cause problems if you need to pass your own SSH arguments,
    # but for our use case it's OK.
    
    # Only accept the options starting with '--'
    declare -a opts
    for s_arg in "${@: 1: $# -2}" ; do
        if [[ "${s_arg}" == --* ]] ; then
            opts+=("${s_arg}")
        fi
    done
    
    exec az ssh vm --ip "${host}" -- "${opts[@]}" "${cmd}"
    

    Save this script as ssh-wrapper.sh in the same directory as your Ansible playbook.

    Now, we need to configure the playbook to use the wrapper. We also need to configure Ansible to use piped transfer rather than scp or sftp. This will pipe files through SSH, so scp and sftp are not required.

    The start of your playbook should look something like this:

    ---
    - name: Example
      hosts: all
      vars:
        # Need piped transfer because az ssh vm doesn't support scp or sftp
        ansible_ssh_transfer_method: piped
        ansible_ssh_executable: ./ssh-wrapper.sh
    
      tasks:
        - name: Test
          ansible.builtin.command: pwd
    

    Assuming you have previously run az login, and are able to login to the VM with az ssh login --ip 1.2.3.4, you should be able to simply run the Ansible playbook as follows:

    ansible-playbook playbook.yml -i 1.2.3.4,
    

    Edit: Python version of wrapper

    The following Python SSH wrapper has several advantages over the bash wrapper above:

    1. You can wrap it in a Python package and pip install it
    2. It knows what the valid SSH options are
    3. It only removes options which are known to cause problems with az ssh.
    #!/usr/bin/env python3
    
    import getopt
    import os
    import shutil
    import sys
    
    
    def usage():
        usage = """
    SSH wrapper for az ssh
    
    Usage:
    
    This wrapper emulates OpenSSH, but translates the arguments and
    supplies them to az ssh so that it can be used by Ansible as the
    ansible_ssh_executable.
    
    It also filters out ControlMaster and ControlPersist options, as these
    appear to block for the duration of the specified ControlPersist value
    when passed to az ssh. See the following GitHub issue for details:
    https://github.com/Azure/azure-cli-extensions/issues/7285
    
    This wrapper supports all arguments supported by OpenSSH 8.9p1:
    
        az-ssh [-46AaCfGgKkMNnqsTtVvXxYy] [-B bind_interface]
               [-b bind_address] [-c cipher_spec] [-D [bind_address:]port]
               [-E log_file] [-e escape_char] [-F configfile] [-I pkcs11]
               [-i identity_file] [-J [user@]host[:port]] [-L address]
               [-l login_name] [-m mac_spec] [-O ctl_cmd] [-o option] [-p port]
               [-Q query_option] [-R address] [-S ctl_path] [-W host:port]
               [-w local_tun[:remote_tun]] destination [command [argument ...]]
    
    See the ssh man page for details.
    
    Example:
    
        az-ssh 1.2.3.4
    """
        print(usage)
    
    
    def opt_filter(opt):
        """Filter out options which cause problems for az ssh
    
        For some reason, setting the Control* options causes az ssh to hang
        for the duration of ControlPersist"""
        return not (opt[0] == "-o" and opt[1].startswith("Control"))
    
    
    def main():
        options = "46AaCfGgKkMNnqsTtVvXxYyB:b:c:D:E:e:f:I:i:J:L:l:m:O:o:p:Q:R:S:W:w:"
    
        try:
            opts, args = getopt.getopt(sys.argv[1:], options)
            destination = args.pop(0)
        except (getopt.GetoptError, IndexError) as err:
            print(f"Error: {err}")
            usage()
            sys.exit(2)
    
        az_path = shutil.which("az")
        # Work around quoting issue on Windows (still works on Linux)
        az_path = f'"{az_path}"'
    
        ssh_opts = []
        for opt in filter(opt_filter, opts):
            # Only keep truthy options. e.g.
            # ('-t', '') --> ('t',)
            # (Yes, this is important.)
            ssh_opts += filter(lambda o: o, opt)
    
        exec_args = [az_path, "ssh", "vm", "--ip", destination]
        if ssh_opts or args:
            exec_args.extend(["--", *ssh_opts, *args])
    
        os.execvp("az", exec_args)
    
    
    if __name__ == "__main__":
        main()