SSH fingerprints in the DNS provide a method for an OpenSSH client to verify the fingerprint upon first contact with a host and, if the client's DNS is DNSSEC-validated, the OpenSSH client will by-pass asking a user for fingerprint confirmation.

We've previously discussed methods for obtaining SSHFP records, and I'd like to present yet another method I'm working on for collecting SSHFP records via Ansible.

Ansible's setup module, which is typically run as the first step in a playbook, collects information (i.e. "facts") about the target nodes and returns that as a JSON object. Contained in that are the public RSA and DSA SSH host keys. (I'll show just one here for brevity.):

    "ansible_distribution": "Ubuntu", 
    "ansible_ssh_host_key_rsa_public": "AAAAB3NzaC1.......c3oK+OlQ==", 
    "ansible_swapfree_mb": 1087,

These facts are made available to templates and modules as so-called hostvars -- an array of facts indexed by the nodename. That's all very fine and dandy, but how to we convert the SSH public key to a fingerprint ready to be stored in the DNS?

One method is to use an Ansible action plugin, which is very much like a module: it's written in Python, but the difference is that while modules run on nodes, an action plugin runs on the managing server, typically also once per target node configured in the playbook. However, with a bit of magic, the action plugin runs once only. (Thanks to Daniel Hokka Zakrisson for the succinct explanation.)

Consider the following Ansible playbook:

- hosts:
  gather_facts: true
  - zonename: ansible.aa
  - ttl: 3600
    - name: Shell | touch a file
      action: shell touch /tmp/somefile
    - name: Local | Process SSH host keys to SSHFP and update DNS dynamically
      local_action: sshfp_a zone="{{zonename}}"
      register: dns
    - name: BIND | Create zone file
      local_action: template dest=/tmp/zonefile

The shell module will be run on all three nodes just after the setup module. (The latter is implicitly invoked on each node because of gather_facts which defaults to true.) Next up is our action plugin called sshfp_a which is run as a local action, i.e. on the management system, followed by an invocation of template which is also run locally. Look at the output I get when I run the play:

PLAY [;;] ********************* 

GATHERING FACTS ********************* 
ok: []
ok: []
ok: []

TASK: [Shell | touch a file] ********************* 
changed: []
changed: []
changed: []

TASK: [Local | Process SSH host keys to SSHFP and update DNS dynamically] ********************* 
ok: []

TASK: [BIND | Create zone file] ********************* 
ok: []
ok: []
ok: []

PLAY RECAP *********************                  : ok=4    changed=1    unreachable=0    failed=0                  : ok=4    changed=1    unreachable=0    failed=0               : ok=4    changed=1    unreachable=0    failed=0

The setup, shell and template modules are each indeed run three times. Our action plugin is run once only.

Ansible action plugin

The diagram attempts to illustrate what Ansible does.

Getting back to the topic of SSHFP fingerprints, our action plugin (sshfp_a) obtains all hostvars gathered during the setup-phase and can use the information contained therein. The two values I'm interested in here are ansible_ssh_host_key_rsa_public and ansible_ssh_host_key_dsa_public from which we determine the SSH fingerprint using code I swiped from Paul Wouters when I wrote facts2sshfp. This is the action plugin:

import sys
import json
import base64
    import hashlib
    digest = hashlib.sha1
except ImportError:
    import sha
    digest =
import ansible

from ansible.callbacks import vv
from ansible.errors import AnsibleError as ae
from ansible.runner.return_data import ReturnData
from ansible.utils import parse_kv, template

class ActionModule(object):
    ''' Process hostvars '''

    ### Make sure runs once per play only

    def __init__(self, runner):
        self.runner = runner

    def run(self, conn, tmp, module_name, module_args, inject, complex_args):
        args = parse_kv(self.runner.module_args)
        if not 'zone' in args:
            raise ae("'zone' is a required argument.")
        zone_default = args['zone']

        vv("phost ActionModule: zone=%s" % (zone_default))

        changed = False
        zonedata = {}

        for host in inject['hostvars']:
            data = inject['hostvars'][host]
            hostname = data['inventory_hostname_short']
            zone = data.get('zone', zone_default)
            sshkeys = {}

            for keytype in [ 'rsa', 'dsa' ]:
                ak = "ansible_ssh_host_key_%s_public" % (keytype)
                if ak in data:
                    keyblob = data[ak]
                    rdata = create_sshfp(hostname, keytype, keyblob)

                    # DO SOMETHING with (keytype, hostname, rdata))

                    sshkeys[keytype] = rdata
            zonedata[hostname] = { 'sshkeys' : sshkeys, 'zone' : zone }

        # Remove 'zonedata' if you never want to "register: " it.
        result = { 'changed': changed, 'msg': 'Gracias', 'zonedata': zonedata }

        return ReturnData(conn=conn, comm_ok=True, result=result)

def create_sshfp(hostname, keytype, keyblob):
        """Creates an SSH fingerprint"""
        if keytype == "rsa":
                keytype = "1"
                if keytype == "dsa":
                        keytype = "2"
                        return ""
                rawkey = base64.b64decode(keyblob)
        except TypeError:
                print >> sys.stderr, "FAILED on hostname "+hostname+" with keyblob "+keyblob
                return "ERROR"

        fpsha1 = digest(rawkey).hexdigest().upper()
        return "%s 1 %s" % (keytype, fpsha1)

Note how BYPASS_HOST_LOOP ensures our action plugin is run once per play only. Further, note where it says "Do something ...". Here's where we would process the SSH fingerprints. For example:

  • I use Dynamic DNS Updates to update a BIND zone on the fly. (Dynamic updates are also on PowerDNS' horizon and work as well.)
  • Update an SQL database or an LDAP directory with the fingerprints, e.g. for PowerDNS.
  • Create an CSV file to process out of band.
  • etc.

Supposing I don't want to update DNS on the fly, I could (as mentioned above) store the SSHFP records in a file and massage those later. But how about we do that directly, using an Ansible template?

Our action plugin collects and returns the information it has gathered, and makes this available as zonedata.

   "sushi" : {
      "sshkeys" : {
         "rsa" : "1 1 2837A34AE91D70A79C462B54696C39AA485358E2",
         "dsa" : "2 1 3AC69CF2AD430AACBA52A5BBA98F82A42FCA635B"
      "zone" : "${zonename}"
   "tikka" : {
      "sshkeys" : {
         "rsa" : "1 1 68C78C2EE5CAB29627959308F4F24C296614279A",
         "dsa" : "2 1 AA40E5A7A07853AC6D6AFAB1068E8FBDEAEF151A"
      "zone" : ""
   "ubu10" : {
      "sshkeys" : {
         "rsa" : "1 1 7E7A55CEA3B8E15528665A6781CA7C35190CF0EB",
         "dsa" : "2 1 CC17F14DA60CF38E809FE58B10D0F22680D59D08"
      "zone" : "${zonename}"

Thanks to how Ansible can register variables, we can collect this data in the play and hand it over to the template module as a variable called "dns". Here's the relevant portion of the playbook above:

- name: Local | Process SSH host keys to SSHFP and update DNS dynamically
  local_action: sshfp_a zone="{{zonename}}"
  register: dns
- name: BIND | Create zone file
  local_action: template dest=/tmp/zonefile

With a template consisting of

; {{ ansible_managed }}
; include this file containing SSHFP RRsets
; in zone's master file

$ORIGIN {{ zonename }}.
$TTL {{ ttl }}

{% for host, v in dns.zonedata.iteritems() %}
{{ "%-20s"|format(host)  }} IN SSHFP {{ v['sshkeys']['rsa'] }}  ; RSA ({{ v['zone']}})
{{ "%-20s"|format('')    }} IN SSHFP {{ v['sshkeys']['dsa'] }}  ; DSA

{% endfor %}

we create a lovely zone file we can include in our zone master file in BIND or NSD:

; Ansible managed: /etc/ansible/ modified on 2012-11-05 10:22:24 by jpm on
; include this file containing SSHFP RRsets
; in zone's master file

$ORIGIN ansible.aa.
$TTL 3600

ubu10                IN SSHFP 1 1 7E7A55CEA3B8E15528665A6781CA7C35190CF0EB  ; RSA (ansible.aa)
                     IN SSHFP 2 1 CC17F14DA60CF38E809FE58B10D0F22680D59D08  ; DSA

tikka                IN SSHFP 1 1 68C78C2EE5CAB29627959308F4F24C296614279A  ; RSA (
                     IN SSHFP 2 1 AA40E5A7A07853AC6D6AFAB1068E8FBDEAEF151A  ; DSA

sushi                IN SSHFP 1 1 2837A34AE91D70A79C462B54696C39AA485358E2  ; RSA (ansible.aa)
                     IN SSHFP 2 1 3AC69CF2AD430AACBA52A5BBA98F82A42FCA635B  ; DSA

With Ansible, SSH host key fingerprints in the DNS, a DNSSEC-validating resolver, and an up-to-date copy of SSH, we can ensure people login to boxes over SSH safely.

On the other hand, most admins I know ignore the key fingerprint warning upon first connect anyway, so sometimes I ask myself what the point is ... (sigh)


Action plugins go in a special directory, the path to which I configure in ansible.cfg:

nocows = 1
action_plugins = /etc/ansible/jp.action_plugins

In our example, the file (note the extension) is dropped into the action_plugins directory; it need neither be executable nor have a shebang line.

Flattr this
DNS and Ansible :: 03 Nov 2012 :: e-mail


blog comments powered by Disqus