Syncing SSH keys on Cisco IOS XR with a custom Ansible module

Vincent Bernat

The cisco.​iosxr col­lec­tion from An­si­ble Gal­axy pro­vides an iosxr_​user mod­ule to man­age lo­cal users, along with their SSH keys. How­ever, the mod­ule is quite slow, does not dis­play a diff for changed SSH keys, never sig­nals a change when a key is mod­i­fied, and does not delete ob­so­lete keys. Let’s write a cus­tom An­si­ble mod­ule man­ag­ing only the SSH keys while fix­ing these issues.

Notice

I rec­om­mend that you read “Writ­ing a cus­tom An­si­ble mod­ule” as an in­tro­duction.

How to add an SSH key to a user#

Adding SSH keys to users in Cisco IOS XR is quite un­doc­u­mented. First, you need to en­code the key with the “ssh-rsa” key ASN.1 for­mat, like an OpenSSH pub­lic key, but with­out the base64-encoding:

$ awk '{print $2}' id_rsa.pub \
>   | base64 -d \
>   > publickey_vincent.raw

Then, you up­load the key with SCP to harddisk:/​publickey_​vincent.​raw and im­port it for the cur­rent user with the fol­low­ing IOS command:

crypto key import authentication rsa harddisk:/publickey_vincent.b64

How­ever, if you want to im­port a key for an­other user, you need to be part of the root-system group:

username vincent
 group root-lr
 group root-system

With the fol­low­ing ad­min com­mand, you can at­tach a key to an­other user:

admin crypto key import authentication rsa username cedric harddisk:/publickey_cedric.b64

Code#

The mod­ule has the fol­low­ing sig­na­ture and it in­stalls the spec­i­fied key for each user and re­moves keys from re­tired users—the ones we do not specify.

iosxr_users:
  keys:
    vincent: ssh-rsa AAAAB3NzaC1yc2EAA[…]ymh+YrVWLZMJR
    cedric:  ssh-rsa AAAAB3NzaC1yc2EAA[…]RShPA8w/8eC0n

Prerequisites#

Un­like the iosxr_​user mod­ule, our cus­tom mod­ule only han­dles SSH keys, one per user. There­fore, the user def­i­ni­tions have to al­ready ex­ist in the run­ning con­fig­u­ra­tion.1 More­over, the user de­fined in ansible_​user needs to be in the root-system group. The cisco.​iosxr col­lec­tion must also be in­stalled as the mod­ule re­lies on its code.

When run­ning the mod­ule, ansible_​connection needs to be set to network_​cli and ansible_​network_os to iosxr. These vari­ables are usu­ally de­fined in the in­ventory.

Module definition#

Start­ing from the skele­ton de­scribed in the pre­vi­ous ar­ti­cle, we de­fine the module:

module_args = dict(
    keys=dict(type='dict', elements='str', required=True),
)

module = AnsibleModule(
    argument_spec=module_args,
    supports_check_mode=True
)

result = dict(
    changed=False
)

Getting the installed keys#

The next step is to re­trieve the keys cur­rently in­stalled. This can be done with the fol­low­ing command:

# show crypto key authentication rsa all
Key label: vincent
Type     : RSA public key authentication
Size     : 2048
Imported : 16:17:08 UTC Tue Aug 11 2020
Data     :
 30820122 300D0609 2A864886 F70D0101 01050003 82010F00 3082010A 02820101
 00D81E5B A73D82F3 77B1E4B5 949FB245 60FB9167 7CD03AB7 ADDE7AFE A0B83174
 A33EC0E6 1C887E02 2338367A 8A1DB0CE 0C3FBC51 15723AEB 07F301A4 B1A9961A
 2D00DBBD 2ABFC831 B0B25932 05B3BC30 B9514EA1 3DC22CBD DDCA6F02 026DBBB6
 EE3CFADA AFA86F52 CAE7620D 17C3582B 4422D24F D68698A5 52ED1E9E 8E41F062
 7DE81015 F33AD486 C14D0BB1 68C65259 F9FD8A37 8DE52ED0 7B36E005 8C58516B
 7EA6C29A EEE0833B 42714618 50B3FFAC 15DBE3EF 8DA5D337 68DAECB9 904DE520
 2D627CEA 67E6434F E974CF6D 952AB2AB F074FBA3 3FB9B9CC A0CD0ADC 6E0CDB2A
 6A1CFEBA E97AF5A9 1FE41F6C 92E1F522 673E1A5F 69C68E11 4A13C0F3 0FFC782D
 27020301 0001

[…]

ansible_​collections.​cisco.​iosxr.​plugins.​module_​utils.​network.​iosxr.​iosxr con­tains a run_​commands() func­tion we can use:

command = "show crypto key authentication rsa all"
out = run_commands(module, command)
out = out[0].replace(' \n', '\n')

A com­mon li­brary to parse a com­mand out­put is textfsm: a Python mod­ule us­ing a tem­plate-based state ma­chine for pars­ing semi-for­mat­ted text.

template = r"""
Value Required Label (\w+)
Value Required,List Data ([A-F0-9 ]+)

Start
 ^Key label: ${Label}
 ^Data\s+: -> GetData

GetData
 ^ ${Data}
 ^$$ -> Record Start
""".lstrip()

re_table = textfsm.TextFSM(io.StringIO(template))
got = {data[0]: "".join(data[1]).replace(' ', '')
       for data in re_table.ParseText(out)}

got is a dic­tio­nary as­so­ci­at­ing key la­bels, con­sid­ered as user­names, with a hexa­dec­i­mal rep­re­sen­ta­tion of the pub­lic key cur­rently in­stalled. It looks like this:

>>> pprint(got)
{'alfred': '30820122300D0609[…]6F0203010001',
 'cedric': '30820122300D0609[…]710203010001',
 'vincent': '30820122300D0609[…]270203010001'}

Comparing with the wanted keys#

Let’s now build the wanted dic­tio­nary us­ing the same struc­ture. In module.​params['keys'], we have a dic­tio­nary as­so­ci­at­ing user­names to pub­lic SSH keys in the OpenSSH format:

>>> pprint(module.params['keys'])
{'cedric': 'ssh-rsa AAAAB3NzaC1yc2[…]',
 'vincent': 'ssh-rsa AAAAB3NzaC1yc2[…]'}

We need to con­vert these keys to the same hexa­dec­i­mal rep­re­sen­ta­tion used by Cisco above. The ssh-keygen com­mand and some glue can do the con­version:2

$ ssh-keygen -f id_rsa.pub -e -mPKCS8 \
>  | grep -v '^---' \
>  | base64 -d \
>  | hexdump -e '4/1 "%0.2X"'
30820122300D06092[…]782D270203010001

As­sum­ing we have a ssh2cisco() func­tion do­ing that, we can build the wanted dic­tionary:

wanted = {k: ssh2cisco(v)
          for k, v in module.params['keys'].items()}

Applying changes#

Back to the skele­ton de­scribed in the pre­vi­ous ar­ti­cle, the last step is to ap­ply the changes if there is a dif­fer­ence be­tween got and wanted when not run­ning with check mode. The part com­par­ing got and wanted is taken ver­ba­tim from the skele­ton module:

if got != wanted:
    result['changed'] = True
    result['diff'] = dict(
        before=yaml.safe_dump(got),
        after=yaml.safe_dump(wanted)
    )

if module.check_mode or not result['changed']:
    module.exit_json(**result)

Let’s copy the new or changed keys and at­tach them to their re­spec­tive users. For this pur­pose, we re­use the get_​connection() and copy_​file() func­tions from ansible_​collections.​cisco.​iosxr.​plugins.​module_​utils.​network.​iosxr.iosxr.

conn = get_connection(module)
for user in wanted:
    if user not in got or wanted[user] != got[user]:
        dst = f"/harddisk:/publickey_{user}.raw"
        with tempfile.NamedTemporaryFile() as src:
            decoded = base64.b64decode(
                module.params['keys'][user].split()[1])
            src.write(decoded)
            src.flush()
            copy_file(module, src.name, dst)
    command = ("admin crypto key import authentication rsa "
               f"username {user} {dst}")
    conn.send_command(command, prompt="yes/no", answer="yes")

Then, we re­move ob­so­lete keys:

for user in got:
    if user not in wanted:
        command = ("admin crypto key zeroize authentication rsa "
                   f"username {user}")
        conn.send_command(command, prompt="yes/no", answer="yes")

The com­plete code is avail­able on GitHub. Com­pared to the iosxr_​user mod­ule, this one dis­plays a diff when run­ning with --diff, cor­rectly sig­nals a change, is faster, 3 and deletes un­wanted SSH keys. How­ever, it is un­able to cre­ate users and can­not con­fig­ure pass­words or mul­ti­ple SSH keys.