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
This commit is contained in:
Matthew Vernon 2014-09-30 14:08:26 +01:00 committed by Matt Clay
parent fcf8223dfe
commit e76ad916ef

View file

@ -0,0 +1,234 @@
#!/usr/bin/env python
"""
Ansible module to manage the ssh known_hosts file.
Copyright(c) 2014, Matthew Vernon <mcv21@cam.ac.uk>
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 <http://www.gnu.org/licenses/>.
"""
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()