summaryrefslogtreecommitdiffstats
path: root/lib/ansible/modules/known_hosts.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/ansible/modules/known_hosts.py')
-rw-r--r--lib/ansible/modules/known_hosts.py365
1 files changed, 365 insertions, 0 deletions
diff --git a/lib/ansible/modules/known_hosts.py b/lib/ansible/modules/known_hosts.py
new file mode 100644
index 0000000..b0c8888
--- /dev/null
+++ b/lib/ansible/modules/known_hosts.py
@@ -0,0 +1,365 @@
+
+# Copyright: (c) 2014, Matthew Vernon <mcv21@cam.ac.uk>
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+
+DOCUMENTATION = r'''
+---
+module: known_hosts
+short_description: Add or remove a host from the C(known_hosts) file
+description:
+ - The C(known_hosts) module lets you add or remove a host keys from the C(known_hosts) file.
+ - Starting at Ansible 2.2, multiple entries per host are allowed, but only one for each key type supported by ssh.
+ This is useful if you're going to want to use the M(ansible.builtin.git) module over ssh, for example.
+ - If you have a very large number of host keys to manage, you will find the M(ansible.builtin.template) module more useful.
+version_added: "1.9"
+options:
+ name:
+ aliases: [ 'host' ]
+ description:
+ - The host to add or remove (must match a host specified in key). It will be converted to lowercase so that ssh-keygen can find it.
+ - Must match with <hostname> or <ip> present in key attribute.
+ - For custom SSH port, C(name) needs to specify port as well. See example section.
+ type: str
+ required: true
+ key:
+ description:
+ - The SSH public host key, as a string.
+ - Required if C(state=present), optional when C(state=absent), in which case all keys for the host are removed.
+ - The key must be in the right format for SSH (see sshd(8), section "SSH_KNOWN_HOSTS FILE FORMAT").
+ - Specifically, the key should not match the format that is found in an SSH pubkey file, but should rather have the hostname prepended to a
+ line that includes the pubkey, the same way that it would appear in the known_hosts file. The value prepended to the line must also match
+ the value of the name parameter.
+ - Should be of format C(<hostname[,IP]> ssh-rsa <pubkey>).
+ - For custom SSH port, C(key) needs to specify port as well. See example section.
+ type: str
+ path:
+ description:
+ - The known_hosts file to edit.
+ - The known_hosts file will be created if needed. The rest of the path must exist prior to running the module.
+ default: "~/.ssh/known_hosts"
+ type: path
+ hash_host:
+ description:
+ - Hash the hostname in the known_hosts file.
+ type: bool
+ default: "no"
+ version_added: "2.3"
+ state:
+ description:
+ - I(present) to add the host key.
+ - I(absent) to remove it.
+ choices: [ "absent", "present" ]
+ default: "present"
+ type: str
+attributes:
+ check_mode:
+ support: full
+ diff_mode:
+ support: full
+ platform:
+ platforms: posix
+extends_documentation_fragment:
+ - action_common_attributes
+author:
+- Matthew Vernon (@mcv21)
+'''
+
+EXAMPLES = r'''
+- name: Tell the host about our servers it might want to ssh to
+ ansible.builtin.known_hosts:
+ path: /etc/ssh/ssh_known_hosts
+ name: foo.com.invalid
+ key: "{{ lookup('ansible.builtin.file', 'pubkeys/foo.com.invalid') }}"
+
+- name: Another way to call known_hosts
+ ansible.builtin.known_hosts:
+ name: host1.example.com # or 10.9.8.77
+ key: host1.example.com,10.9.8.77 ssh-rsa ASDeararAIUHI324324 # some key gibberish
+ path: /etc/ssh/ssh_known_hosts
+ state: present
+
+- name: Add host with custom SSH port
+ ansible.builtin.known_hosts:
+ name: '[host1.example.com]:2222'
+ key: '[host1.example.com]:2222 ssh-rsa ASDeararAIUHI324324' # some key gibberish
+ path: /etc/ssh/ssh_known_hosts
+ state: present
+'''
+
+# Makes sure public host keys are present or absent in the given known_hosts
+# file.
+#
+# Arguments
+# =========
+# name = hostname whose key should be added (alias: host)
+# key = line(s) to add to known_hosts file
+# path = the known_hosts file to edit (default: ~/.ssh/known_hosts)
+# hash_host = yes|no (default: no) hash the hostname in the known_hosts file
+# state = absent|present (default: present)
+
+import base64
+import errno
+import hashlib
+import hmac
+import os
+import os.path
+import re
+import tempfile
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils._text import to_bytes, to_native
+
+
+def enforce_state(module, params):
+ """
+ Add or remove key.
+ """
+
+ host = params["name"].lower()
+ key = params.get("key", None)
+ path = params.get("path")
+ hash_host = params.get("hash_host")
+ state = params.get("state")
+ # Find the ssh-keygen binary
+ sshkeygen = module.get_bin_path("ssh-keygen", True)
+
+ if not key and state != "absent":
+ module.fail_json(msg="No key specified when adding a host")
+
+ if key and hash_host:
+ key = hash_host_key(host, key)
+
+ # Trailing newline in files gets lost, so re-add if necessary
+ if key and not key.endswith('\n'):
+ key += '\n'
+
+ sanity_check(module, host, key, sshkeygen)
+
+ found, replace_or_add, found_line = search_for_host_key(module, host, key, path, sshkeygen)
+
+ params['diff'] = compute_diff(path, found_line, replace_or_add, state, key)
+
+ # check if we are trying to remove a non matching key,
+ # in that case return with no change to the host
+ if state == 'absent' and not found_line and key:
+ params['changed'] = False
+ return params
+
+ # We will change state if found==True & state!="present"
+ # or found==False & state=="present"
+ # i.e found 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_add or (state == "present") != found,
+ diff=params['diff'])
+
+ # Now do the work.
+
+ # Only remove whole host if found and no key provided
+ if found and not key and state == "absent":
+ module.run_command([sshkeygen, '-R', host, '-f', path], check_rc=True)
+ params['changed'] = True
+
+ # Next, add a new (or replacing) entry
+ if replace_or_add or found != (state == "present"):
+ try:
+ inf = open(path, "r")
+ except IOError as e:
+ if e.errno == errno.ENOENT:
+ inf = None
+ else:
+ module.fail_json(msg="Failed to read %s: %s" % (path, str(e)))
+ try:
+ with tempfile.NamedTemporaryFile(mode='w+', dir=os.path.dirname(path), delete=False) as outf:
+ if inf is not None:
+ for line_number, line in enumerate(inf):
+ if found_line == (line_number + 1) and (replace_or_add or state == 'absent'):
+ continue # skip this line to replace its key
+ outf.write(line)
+ inf.close()
+ if state == 'present':
+ outf.write(key)
+ except (IOError, OSError) as e:
+ module.fail_json(msg="Failed to write to file %s: %s" % (path, to_native(e)))
+ else:
+ module.atomic_move(outf.name, path)
+
+ params['changed'] = True
+
+ return params
+
+
+def sanity_check(module, host, key, sshkeygen):
+ '''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.
+ sshkeygen is the path to ssh-keygen, found earlier with get_bin_path
+ '''
+ # If no key supplied, we're doing a removal, and have nothing to check here.
+ if not key:
+ 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.
+
+ if re.search(r'\S+(\s+)?,(\s+)?', host):
+ module.fail_json(msg="Comma separated list of names is not supported. "
+ "Please pass a single name to lookup in the known_hosts file.")
+
+ with tempfile.NamedTemporaryFile(mode='w+') as outf:
+ try:
+ outf.write(key)
+ outf.flush()
+ except IOError as e:
+ module.fail_json(msg="Failed to write to temporary file %s: %s" %
+ (outf.name, to_native(e)))
+
+ sshkeygen_command = [sshkeygen, '-F', host, '-f', outf.name]
+ rc, stdout, stderr = module.run_command(sshkeygen_command)
+
+ 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, sshkeygen):
+ '''search_for_host_key(module,host,key,path,sshkeygen) -> (found,replace_or_add,found_line)
+
+ Looks up host and keytype in the known_hosts file path; if it's there, looks to see
+ if one of those entries matches key. Returns:
+ found (Boolean): is host found in path?
+ replace_or_add (Boolean): is the key in path different to that supplied by user?
+ found_line (int or None): the line where a key of the same type was found
+ if found=False, then replace is always False.
+ sshkeygen is the path to ssh-keygen, found earlier with get_bin_path
+ '''
+ if os.path.exists(path) is False:
+ return False, False, None
+
+ sshkeygen_command = [sshkeygen, '-F', host, '-f', path]
+
+ # openssh >=6.4 has changed ssh-keygen behaviour such that it returns
+ # 1 if no host is found, whereas previously it returned 0
+ rc, stdout, stderr = module.run_command(sshkeygen_command, check_rc=False)
+ if stdout == '' and stderr == '' and (rc == 0 or rc == 1):
+ return False, False, None # 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))
+
+ # If user supplied no key, we don't want to try and replace anything with it
+ if not key:
+ return True, False, None
+
+ lines = stdout.split('\n')
+ new_key = normalize_known_hosts_key(key)
+
+ for lnum, l in enumerate(lines):
+ if l == '':
+ continue
+ elif l[0] == '#': # info output from ssh-keygen; contains the line number where key was found
+ try:
+ # This output format has been hardcoded in ssh-keygen since at least OpenSSH 4.0
+ # It always outputs the non-localized comment before the found key
+ found_line = int(re.search(r'found: line (\d+)', l).group(1))
+ except IndexError:
+ module.fail_json(msg="failed to parse output of ssh-keygen for line number: '%s'" % l)
+ else:
+ found_key = normalize_known_hosts_key(l)
+ if new_key['host'][:3] == '|1|' and found_key['host'][:3] == '|1|': # do not change host hash if already hashed
+ new_key['host'] = found_key['host']
+ if new_key == found_key: # found a match
+ return True, False, found_line # found exactly the same key, don't replace
+ elif new_key['type'] == found_key['type']: # found a different key for the same key type
+ return True, True, found_line
+
+ # No match found, return found and replace, but no line
+ return True, True, None
+
+
+def hash_host_key(host, key):
+ hmac_key = os.urandom(20)
+ hashed_host = hmac.new(hmac_key, to_bytes(host), hashlib.sha1).digest()
+ parts = key.strip().split()
+ # @ indicates the optional marker field used for @cert-authority or @revoked
+ i = 1 if parts[0][0] == '@' else 0
+ parts[i] = '|1|%s|%s' % (to_native(base64.b64encode(hmac_key)), to_native(base64.b64encode(hashed_host)))
+ return ' '.join(parts)
+
+
+def normalize_known_hosts_key(key):
+ '''
+ Transform a key, either taken from a known_host file or provided by the
+ user, into a normalized form.
+ The host part (which might include multiple hostnames or be hashed) gets
+ replaced by the provided host. Also, any spurious information gets removed
+ from the end (like the username@host tag usually present in hostkeys, but
+ absent in known_hosts files)
+ '''
+ key = key.strip() # trim trailing newline
+ k = key.split()
+ d = dict()
+ # The optional "marker" field, used for @cert-authority or @revoked
+ if k[0][0] == '@':
+ d['options'] = k[0]
+ d['host'] = k[1]
+ d['type'] = k[2]
+ d['key'] = k[3]
+ else:
+ d['host'] = k[0]
+ d['type'] = k[1]
+ d['key'] = k[2]
+ return d
+
+
+def compute_diff(path, found_line, replace_or_add, state, key):
+ diff = {
+ 'before_header': path,
+ 'after_header': path,
+ 'before': '',
+ 'after': '',
+ }
+ try:
+ inf = open(path, "r")
+ except IOError as e:
+ if e.errno == errno.ENOENT:
+ diff['before_header'] = '/dev/null'
+ else:
+ diff['before'] = inf.read()
+ inf.close()
+ lines = diff['before'].splitlines(1)
+ if (replace_or_add or state == 'absent') and found_line is not None and 1 <= found_line <= len(lines):
+ del lines[found_line - 1]
+ if state == 'present' and (replace_or_add or found_line is None):
+ lines.append(key)
+ diff['after'] = ''.join(lines)
+ return diff
+
+
+def main():
+
+ module = AnsibleModule(
+ argument_spec=dict(
+ name=dict(required=True, type='str', aliases=['host']),
+ key=dict(required=False, type='str', no_log=False),
+ path=dict(default="~/.ssh/known_hosts", type='path'),
+ hash_host=dict(required=False, type='bool', default=False),
+ state=dict(default='present', choices=['absent', 'present']),
+ ),
+ supports_check_mode=True
+ )
+
+ results = enforce_state(module, module.params)
+ module.exit_json(**results)
+
+
+if __name__ == '__main__':
+ main()