From d821a39131aedf39fa00547b672745b74002b16d Mon Sep 17 00:00:00 2001 From: Matthew Vernon Date: Tue, 30 Sep 2014 14:08:26 +0100 Subject: [PATCH] known_hosts: manage ssh known_hosts files with ansible. The known_hosts module lets you add or remove a host from the known_hosts file. This is useful if you're going to want to use the git module over ssh, for example. If you have a very large number of host keys to manage, you will find the template module more useful. This was pull request 7840 from the old ansible repo, which was accepted-in-principle but not yet merged. The mailing list thread reading it is: https://groups.google.com/forum/#!topic/ansible-devel/_e7H_VT6UJE/discussion --- system/known_hosts.py | 234 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 system/known_hosts.py diff --git a/system/known_hosts.py b/system/known_hosts.py new file mode 100644 index 00000000000..a7e19d2e5e2 --- /dev/null +++ b/system/known_hosts.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python + +""" +Ansible module to manage the ssh known_hosts file. +Copyright(c) 2014, Matthew Vernon + +This module is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This module is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this module. If not, see . +""" + +DOCUMENTATION = ''' +--- +module: known_hosts +short_description: Add or remove a host from the C(known_hosts) file +description: + - The M(known_hosts) module lets you add or remove a host from the C(known_hosts) file. + This is useful if you're going to want to use the M(git) module over ssh, for example. + If you have a very large number of host keys to manage, you will find the M(template) module more useful. +version_added: "1.6" +options: + host: + description: + - The host to add or remove (must match a host specified in key) + required: true + default: null + key: + description: + - The SSH public host key, as a string (optional if removing the host) + required: true + default: null + path: + description: + - The known_hosts file to edit + required: no + default: "(homedir)+/.ssh/known_hosts" + state: + description: + - I(present) to add the host, I(absent) to remove it. + choices: [ "present", "absent" ] + required: no + default: present +requirements: [ ] +author: Matthew Vernon +''' + +EXAMPLES = ''' +# Example using with_file to set the system known_hosts file +- name: tell the host about our servers it might want to ssh to + known_hosts: path='/etc/ssh/ssh_known_hosts' + host='foo.com.invalid' + key="{{ lookup('file', 'pubkeys/foo.com.invalid') }}" +''' + +# Makes sure public host keys are present or absent in the given known_hosts +# file. +# +# Arguments +# ========= +# host = hostname whose key should be added +# key = line(s) to add to known_hosts file +# path = the known_hosts file to edit (default: ~/.ssh/known_hosts) +# state = absent|present (default: present) + +import os,os.path,tempfile,errno + +def enforce_state(module, params): + """ + Add or remove key. + """ + + host = params["host"] + key = params.get("key",None) + port = params.get("port",None) + #expand the path parameter; otherwise module.add_path_info + #(called by exit_json) unhelpfully says the unexpanded path is absent. + params["path"]=os.path.expanduser(params.get("path")) + path = params.get("path") + state = params.get("state","present") + + #trailing newline in files gets lost, so re-add if necessary + if key is not None and key[-1]!='\n': + key+='\n' + + if key is None and state != "absent": + module.fail_json(msg="No key specified when adding a host") + + sanity_check(module,host,key) + + current,replace=search_for_host_key(module,host,key,path) + + #We will change state if current==True & state!="present" + #or current==False & state=="present" + #i.e (current) XOR (state=="present") + #Alternatively, if replace is true (i.e. key present, and we must change it) + if module.check_mode: + module.exit_json(changed = replace or ((state=="present") != current)) + + #Now do the work. + + #First, remove an extant entry if required + if replace==True or (current==True and state=="absent"): + module.run_command(['ssh-keygen','-R',host,'-f',path], + check_rc=True) + params['changed'] = True + #Next, add a new (or replacing) entry + if replace==True or (current==False and state=="present"): + try: + inf=open(path,"r") + except IOError, e: + if e.errno == errno.ENOENT: + inf=None + else: + module.fail_json(msg="Failed to read %s: %s" % \ + (path,str(e))) + try: + outf=tempfile.NamedTemporaryFile(dir=os.path.dirname(path), + delete=False) + if inf is not None: + for line in inf: + outf.write(line) + inf.close() + outf.write(key) + outf.close() + module.atomic_move(outf.name,path) + except IOError,e: + module.fail_json(msg="Failed to write to file %s: %s" % \ + (path,str(e))) + params['changed'] = True + + return params + +def sanity_check(module,host,key): + '''Check supplied key is sensible + + host and key are parameters provided by the user; If the host + provided is inconsistent with the key supplied, then this function + quits, providing an error to the user. + ''' + #If no key supplied, we're doing a removal, and have nothing to check here. + if key is None: + return + #Rather than parsing the key ourselves, get ssh-keygen to do it + #(this is essential for hashed keys, but otherwise useful, as the + #key question is whether ssh-keygen thinks the key matches the host). + + #The approach is to write the key to a temporary file, + #and then attempt to look up the specified host in that file. + try: + outf=tempfile.NamedTemporaryFile(delete=False) + outf.write(key) + outf.close() + except IOError,e: + module.fail_json(msg="Failed to write to temporary file %s: %s" % \ + (outf.name,str(e))) + rc,stdout,stderr=module.run_command(['ssh-keygen','-F',host, + '-f',outf.name], + check_rc=True) + os.remove(outf.name) + if stdout=='': #host not found + module.fail_json(msg="Host parameter does not match hashed host field in supplied key") + +def search_for_host_key(module,host,key,path): + '''search_for_host_key(module,host,key,path) -> (current,replace) + + Looks up host in the known_hosts file path; if it's there, looks to see + if one of those entries matches key. Returns: + current (Boolean): is host found in path? + replace (Boolean): is the key in path different to that supplied by user? + if current=False, then replace is always False. + ''' + replace=False + if os.path.exists(path)==False: + return False, False + rc,stdout,stderr=module.run_command(['ssh-keygen','-F',host,'-f',path], + check_rc=True) + if stdout=='': #host not found + return False, False + +#If user supplied no key, we don't want to try and replace anything with it + if key is None: + return True, False + + lines=stdout.split('\n') + k=key.strip() #trim trailing newline + #ssh-keygen returns only the host we ask about in the host field, + #even if the key entry has multiple hosts. Emulate this behaviour here, + #otherwise we get false negatives. + #Only necessary for unhashed entries. + if k[0] !='|': + k=k.split() + #The optional "marker" field, used for @cert-authority or @revoked + if k[0][0] == '@': + k[1]=host + else: + k[0]=host + k=' '.join(k) + for l in lines: + if l=='': + continue + if l[0]=='#': #comment + continue + if k==l: #found a match + return True, False #current, not-replace + #No match found, return current and replace + return True, True + +def main(): + + module = AnsibleModule( + argument_spec = dict( + host = dict(required=True, type='str'), + key = dict(required=True, type='str'), + path = dict(default="~/.ssh/known_hosts", type='str'), + state = dict(default='present', choices=['absent','present']), + ), + supports_check_mode = True + ) + + results = enforce_state(module,module.params) + module.exit_json(**results) + +# import module snippets +from ansible.module_utils.basic import * +main()