2015-03-06 19:22:28 +01:00
|
|
|
#!/usr/bin/python
|
2014-09-30 15:08:26 +02:00
|
|
|
|
|
|
|
"""
|
|
|
|
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.
|
2015-03-28 20:20:30 +01:00
|
|
|
version_added: "1.9"
|
2014-09-30 15:08:26 +02:00
|
|
|
options:
|
2015-03-06 19:22:28 +01:00
|
|
|
name:
|
|
|
|
aliases: [ 'host' ]
|
2014-09-30 15:08:26 +02:00
|
|
|
description:
|
|
|
|
- The host to add or remove (must match a host specified in key)
|
|
|
|
required: true
|
|
|
|
default: null
|
|
|
|
key:
|
|
|
|
description:
|
2015-03-06 19:22:28 +01:00
|
|
|
- The SSH public host key, as a string (required if state=present, optional when state=absent, in which case all keys for the host are removed)
|
|
|
|
required: false
|
2014-09-30 15:08:26 +02:00
|
|
|
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: [ ]
|
2015-05-14 16:45:21 +02:00
|
|
|
author: '"Matthew Vernon (@mcv21)" <mcv21@cam.ac.uk>'
|
2014-09-30 15:08:26 +02:00
|
|
|
'''
|
|
|
|
|
|
|
|
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
|
|
|
|
# =========
|
2015-03-06 19:22:28 +01:00
|
|
|
# name = hostname whose key should be added (alias: host)
|
2014-09-30 15:08:26 +02:00
|
|
|
# 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)
|
|
|
|
|
2015-03-06 19:22:28 +01:00
|
|
|
import os
|
|
|
|
import os.path
|
|
|
|
import tempfile
|
|
|
|
import errno
|
2014-09-30 15:08:26 +02:00
|
|
|
|
|
|
|
def enforce_state(module, params):
|
|
|
|
"""
|
|
|
|
Add or remove key.
|
|
|
|
"""
|
|
|
|
|
2015-05-04 21:57:40 +02:00
|
|
|
host = params["name"]
|
2014-09-30 15:08:26 +02:00
|
|
|
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.
|
2015-03-06 19:22:28 +01:00
|
|
|
path = os.path.expanduser(params.get("path"))
|
|
|
|
state = params.get("state")
|
|
|
|
#Find the ssh-keygen binary
|
|
|
|
sshkeygen = module.get_bin_path("ssh-keygen",True)
|
2014-09-30 15:08:26 +02:00
|
|
|
|
|
|
|
#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")
|
|
|
|
|
2015-03-06 19:22:28 +01:00
|
|
|
sanity_check(module,host,key,sshkeygen)
|
2014-09-30 15:08:26 +02:00
|
|
|
|
2015-03-06 19:22:28 +01:00
|
|
|
current,replace=search_for_host_key(module,host,key,path,sshkeygen)
|
2014-09-30 15:08:26 +02:00
|
|
|
|
|
|
|
#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"):
|
2015-03-06 19:22:28 +01:00
|
|
|
module.run_command([sshkeygen,'-R',host,'-f',path],
|
2014-09-30 15:08:26 +02:00
|
|
|
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:
|
2015-04-19 03:50:06 +02:00
|
|
|
outf=tempfile.NamedTemporaryFile(dir=os.path.dirname(path))
|
2014-09-30 15:08:26 +02:00
|
|
|
if inf is not None:
|
|
|
|
for line in inf:
|
|
|
|
outf.write(line)
|
|
|
|
inf.close()
|
|
|
|
outf.write(key)
|
2015-04-19 03:50:06 +02:00
|
|
|
outf.flush()
|
2014-09-30 15:08:26 +02:00
|
|
|
module.atomic_move(outf.name,path)
|
2015-04-02 02:44:28 +02:00
|
|
|
except (IOError,OSError),e:
|
2014-09-30 15:08:26 +02:00
|
|
|
module.fail_json(msg="Failed to write to file %s: %s" % \
|
|
|
|
(path,str(e)))
|
2015-04-19 03:50:06 +02:00
|
|
|
|
|
|
|
try:
|
|
|
|
outf.close()
|
|
|
|
except:
|
|
|
|
pass
|
|
|
|
|
2014-09-30 15:08:26 +02:00
|
|
|
params['changed'] = True
|
2015-04-02 02:44:28 +02:00
|
|
|
|
2014-09-30 15:08:26 +02:00
|
|
|
return params
|
|
|
|
|
2015-03-06 19:22:28 +01:00
|
|
|
def sanity_check(module,host,key,sshkeygen):
|
2014-09-30 15:08:26 +02:00
|
|
|
'''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.
|
2015-03-06 19:22:28 +01:00
|
|
|
sshkeygen is the path to ssh-keygen, found earlier with get_bin_path
|
2014-09-30 15:08:26 +02:00
|
|
|
'''
|
|
|
|
#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:
|
2015-04-19 03:50:06 +02:00
|
|
|
outf=tempfile.NamedTemporaryFile()
|
2014-09-30 15:08:26 +02:00
|
|
|
outf.write(key)
|
2015-04-19 03:50:06 +02:00
|
|
|
outf.flush()
|
2014-09-30 15:08:26 +02:00
|
|
|
except IOError,e:
|
|
|
|
module.fail_json(msg="Failed to write to temporary file %s: %s" % \
|
|
|
|
(outf.name,str(e)))
|
2015-03-06 19:22:28 +01:00
|
|
|
rc,stdout,stderr=module.run_command([sshkeygen,'-F',host,
|
2014-09-30 15:08:26 +02:00
|
|
|
'-f',outf.name],
|
|
|
|
check_rc=True)
|
2015-04-19 03:50:06 +02:00
|
|
|
try:
|
|
|
|
outf.close()
|
|
|
|
except:
|
|
|
|
pass
|
|
|
|
|
2014-09-30 15:08:26 +02:00
|
|
|
if stdout=='': #host not found
|
|
|
|
module.fail_json(msg="Host parameter does not match hashed host field in supplied key")
|
|
|
|
|
2015-03-06 19:22:28 +01:00
|
|
|
def search_for_host_key(module,host,key,path,sshkeygen):
|
|
|
|
'''search_for_host_key(module,host,key,path,sshkeygen) -> (current,replace)
|
2014-09-30 15:08:26 +02:00
|
|
|
|
|
|
|
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.
|
2015-03-06 19:22:28 +01:00
|
|
|
sshkeygen is the path to ssh-keygen, found earlier with get_bin_path
|
2014-09-30 15:08:26 +02:00
|
|
|
'''
|
|
|
|
replace=False
|
|
|
|
if os.path.exists(path)==False:
|
|
|
|
return False, False
|
2015-03-31 18:19:11 +02:00
|
|
|
#openssh >=6.4 has changed ssh-keygen behaviour such that it returns
|
|
|
|
#1 if no host is found, whereas previously it returned 0
|
2015-03-06 19:22:28 +01:00
|
|
|
rc,stdout,stderr=module.run_command([sshkeygen,'-F',host,'-f',path],
|
2015-03-31 18:19:11 +02:00
|
|
|
check_rc=False)
|
|
|
|
if stdout=='' and stderr=='' and (rc==0 or rc==1):
|
|
|
|
return False, False #host not found, no other errors
|
|
|
|
if rc!=0: #something went wrong
|
|
|
|
module.fail_json(msg="ssh-keygen failed (rc=%d,stdout='%s',stderr='%s')" % (rc,stdout,stderr))
|
2014-09-30 15:08:26 +02:00
|
|
|
|
|
|
|
#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(
|
2015-03-06 19:22:28 +01:00
|
|
|
name = dict(required=True, type='str', aliases=['host']),
|
|
|
|
key = dict(required=False, type='str'),
|
2014-09-30 15:08:26 +02:00
|
|
|
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()
|