From aa3da8f2c2d2509dd1dcf04b143aacd8324fefae Mon Sep 17 00:00:00 2001 From: alxwr Date: Fri, 1 Jun 2018 14:11:52 +0200 Subject: [PATCH] Pillar openssh.known_hosts_salt_ssh (#128) * Pillar openssh.known_hosts_salt_ssh * Dropped ill-named file * Fixed aliasing of host names * Improved pillar.example * Opt-in to include localhost * pillar/known_hosts_salt_ssh: clear cache in run() * Dropped forgotten debugging output --- README.rst | 75 ++++++++++++++++++-- _pillar/known_hosts_salt_ssh.sls | 115 +++++++++++++++++++++++++++++++ openssh/files/ssh_known_hosts | 39 +++++++++-- openssh/gather_host_keys.sls | 36 ++++++++++ pillar.example | 18 ++++- 5 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 _pillar/known_hosts_salt_ssh.sls create mode 100644 openssh/gather_host_keys.sls diff --git a/README.rst b/README.rst index 5ace6a7..3d22dda 100644 --- a/README.rst +++ b/README.rst @@ -66,12 +66,15 @@ distribution. Manages the side-wide ssh_known_hosts file and fills it with the public SSH host keys of your minions (collected via the Salt mine) -and of hosts listed in you pillar data. You can restrict the set of minions +and of hosts listed in you pillar data. It's possible to include +minions managed via ``salt-ssh`` by using the ``known_hosts_salt_ssh`` renderer. + +You can restrict the set of minions whose keys are listed by using the pillar data ``openssh:known_hosts:target`` and ``openssh:known_hosts:tgt_type`` (those fields map directly to the corresponding attributes of the ``mine.get`` function). -The Salt mine is used to share the public SSH host keys, you must thus +The **Salt mine** is used to share the public SSH host keys, you must thus configure it accordingly on all hosts that must export their keys. Two mine functions are required, one that exports the keys (one key per line, as they are stored in ``/etc/ssh/ssh_host_*_key.pub``) and one that defines @@ -84,7 +87,7 @@ setup those functions through pillar:: mine_function: cmd.run cmd: cat /etc/ssh/ssh_host_*_key.pub python_shell: True - public_ssh_hostname: + public_ssh_host_names: mine_function: grains.get key: id @@ -103,7 +106,64 @@ IPv6 behind one of those DNS entries matches an IPv4 or IPv6 behind the official hostname of a minion, the alternate DNS name will be associated to the minion's public SSH host key. -To add public keys of hosts not among your minions list them under the +To **include minions managed via salt-ssh** install the ``known_hosts_salt_ssh`` renderer:: + + # in pillar.top: + '*': + - openssh.known_hosts_salt_ssh + + # In your salt/ directory: + # Link the pillar file: + mkdir pillar/openssh + ln -s ../../formulas/openssh-formula/_pillar/known_hosts_salt_ssh.sls pillar/openssh/known_hosts_salt_ssh.sls + +Pillar ``openssh:known_hosts:salt_ssh`` overrides the Salt Mine. + +The pillar is fed by a host key cache. Populate it by applying ``openssh.gather_host_keys`` +to the salt master:: + + salt 'salt-master.example.test' state.apply openssh.gather_host_keys + +The state tries to fetch the SSH host keys via ``salt-ssh``. It calls the command as user +``salt-master`` by default. The username can be changed via Pillar:: + + openssh: + known_hosts: + salt_ssh: + user: salt-master + +It's possible to define aliases for certain hosts:: + + openssh: + known_hosts: + salt_ssh: + public_ssh_host_names: + minion.id: + - minion.id + - alias.of.minion.id + +You can use a cronjob to populate a host key cache:: + + # crontab -e -u salt-master + 0 1 * * * salt 'salt-master.example.test' state.apply openssh.gather_host_keys + +Or just add it to your salt master:: + + # states/top.sls: + base: + salt: + - openssh.known_hosts_salt_ssh + +You can also use a "golden" known hosts file. It overrides the keys fetched by the cronjob. +This lets you re-use the trust estabished in the salt-ssh user's known_hosts file:: + + # In your salt/ directory: (Pillar expects the file here.) + ln -s /home/salt-master/.ssh/known_hosts ./known_hosts + + # Test it: + salt-ssh 'minion' pillar.get 'openssh:known_hosts:salt_ssh' + +To add **public keys of hosts not among your minions** list them under the pillar key ``openssh:known_hosts:static``:: openssh: @@ -112,6 +172,13 @@ pillar key ``openssh:known_hosts:static``:: github.com: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq[...]' gitlab.com: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABA[...]' +Pillar ``openssh:known_hosts:static`` overrides ``openssh:known_hosts:salt_ssh``. + +To **include localhost** and local IP addresses (``127.0.0.1`` and ``::1``) use this Pillar:: + + openssh: + known_hosts: + include_localhost: True ``openssh.moduli`` ----------------------- diff --git a/_pillar/known_hosts_salt_ssh.sls b/_pillar/known_hosts_salt_ssh.sls new file mode 100644 index 0000000..3dac585 --- /dev/null +++ b/_pillar/known_hosts_salt_ssh.sls @@ -0,0 +1,115 @@ +#!py + +import logging +import os.path +import re +import subprocess + +cache = {} +ssh_key_pattern = re.compile("^[^ ]+ (ssh-.+)$") +log = logging.getLogger(__name__) + +def config_dir(): + if '__master_opts__' in __opts__: + # run started via salt-ssh + return __opts__['__master_opts__']['config_dir'] + else: + # run started via salt + return __opts__['config_dir'] + +def cache_dir(): + if '__master_opts__' in __opts__: + # run started via salt-ssh + return __opts__['__master_opts__']['cachedir'] + else: + # run started via salt + return __opts__['cachedir']+'/../master' + +def minions(): + if not 'minions' in cache: + cache['minions'] = __salt__.slsutil.renderer(config_dir() + '/roster') + return cache['minions'] + +def host_variants(minion): + _variants = [minion] + def add_port_variant(host): + if 'port' in minions()[minion]: + _variants.append("[{}]:{}".format(host, minions()[minion]['port'])) + add_port_variant(minion) + if 'host' in minions()[minion]: + host = minions()[minion]['host'] + _variants.append(host) + add_port_variant(host) + return _variants + +def host_keys_from_known_hosts(minion, path): + ''' + Fetches all host keys of the given minion. + ''' + if not os.path.isfile(path): + return [] + pubkeys = [] + def fill_pubkeys(host): + for line in host_key_of(host, path).splitlines(): + match = ssh_key_pattern.search(line) + if match: + pubkeys.append(match.group(1)) + # Try the minion ID and its variants first + for host in host_variants(minion): + fill_pubkeys(host) + # When no keys were found ... + if not pubkeys: + # ... fetch IP addresses via DNS and try them. + for host in (salt['dig.A'](minion) + salt['dig.AAAA'](minion)): + fill_pubkeys(host) + # When not a single key was found anywhere: + if not pubkeys: + log.error("No SSH host key found for {}. " + "You may need to add it to {}.".format(minion, path)) + return "\n".join(pubkeys) + +def host_key_of(host, path): + cmd = ["ssh-keygen", "-H", "-F", host, "-f", path] + call = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + out, err = call.communicate() + if err == '': + return out + else: + log.error("{} failed:\nSTDERR: {}\nSTDOUT: {}".format( + " ".join(cmd), + err, + out + )) + return "" + +def host_keys(minion_id): + # Get keys from trusted known_hosts file + trusted_keys = host_keys_from_known_hosts(minion_id, + config_dir()+'/known_hosts') + if trusted_keys: + print "trusted_keys" + return trusted_keys + # Get keys from host key cache + cache_file = "{}/known_hosts_salt_ssh/{}.pub".format(cache_dir(), minion_id) + try: + with open(cache_file, 'r') as f: + return f.read() + except IOError: + return '' + +def run(): + cache = {} # clear the cache + config = { + 'public_ssh_host_keys': {}, + 'public_ssh_host_names': {} + } + for minion in minions().keys(): + config['public_ssh_host_keys'][minion] = host_keys(minion) + config['public_ssh_host_names'][minion] = minion + return {'openssh': {'known_hosts': {'salt_ssh': config}}} + +# vim: ts=4:sw=4:syntax=python diff --git a/openssh/files/ssh_known_hosts b/openssh/files/ssh_known_hosts index c57a5e9..0839c46 100644 --- a/openssh/files/ssh_known_hosts +++ b/openssh/files/ssh_known_hosts @@ -3,7 +3,7 @@ #} {#- Generates one known_hosts entry per given key #} -{%- macro known_host_entry(host, host_names, keys) %} +{%- macro known_host_entry(host, host_names, keys, include_localhost) %} {#- Get IPv4 and IPv6 addresses from the DNS #} {%- set ip4 = salt['dig.A'](host) -%} @@ -11,7 +11,13 @@ {#- The host names to use are to be found within the dict 'host_names'. #} {#- If there are none, the host is used directly. #} -{%- set names = [host_names.get(host, host)] -%} +{%- set names = host_names.get(host, host) -%} +{%- set names = [names] if names is string else names %} +{%- if include_localhost and host == grains['id'] %} +{%- do names.append('localhost') %} +{%- do names.append('127.0.0.1') %} +{%- do names.append('::1') %} +{%- endif -%} {#- Extract the hostname from the FQDN and add it to the names. #} {%- if use_hostnames is iterable -%} @@ -44,7 +50,7 @@ {%- endmacro -%} {#- Pre-fetch pillar data #} -{%- set target = salt['pillar.get']('openssh:known_hosts:target', '*') -%} +{%- set target = salt['pillar.get']('openssh:known_hosts:target', "*.{}".format(grains['domain'])) -%} {%- set tgt_type = salt['pillar.get']('openssh:known_hosts:tgt_type', 'glob') -%} {%- set keys_function = salt['pillar.get']('openssh:known_hosts:mine_keys_function', 'public_ssh_host_keys') -%} {%- set hostname_function = salt['pillar.get']('openssh:known_hosts:mine_hostname_function', 'public_ssh_hostname') -%} @@ -52,6 +58,7 @@ {%- set hostnames_target_default = '*' if grains['domain'] == '' else "*.{}".format(grains['domain']) -%} {%- set hostnames_target = salt['pillar.get']('openssh:known_hosts:hostnames:target', hostnames_target_default) -%} {%- set hostnames_tgt_type = salt['pillar.get']('openssh:known_hosts:hostnames:tgt_type', 'glob') -%} +{%- set include_localhost = salt['pillar.get']('openssh:known_hosts:include_localhost', False) -%} {#- Lookup IP of all aliases so that when we have a matching IP, we inject the alias name in the SSH known_hosts entry -#} @@ -63,11 +70,33 @@ {%- endfor -%} {%- endfor -%} -{#- Loop over targetted minions -#} +{#- Salt Mine #} {%- set host_keys = salt['mine.get'](target, keys_function, tgt_type=tgt_type) -%} {%- set host_names = salt['mine.get'](target, hostname_function, tgt_type=tgt_type) -%} + +{#- Salt SSH (if any) #} +{%- for minion_id, minion_host_keys in salt['pillar.get']( + 'openssh:known_hosts:salt_ssh:public_ssh_host_keys', + {} +).items() -%} +{%- if salt["match.{}".format(tgt_type)](target, minion_id=minion_id) -%} +{% do host_keys.update({minion_id: minion_host_keys}) %} +{%- endif -%} +{%- endfor -%} +{%- for minion_id, minion_host_names in salt['pillar.get']( + 'openssh:known_hosts:salt_ssh:public_ssh_host_names', + {} +).items() -%} +{%- if salt["match.{}".format(tgt_type)](target, minion_id=minion_id) -%} +{% do host_names.update({minion_id: minion_host_names}) %} +{%- endif -%} +{%- endfor %} + +{#- Static Pillar data #} {%- do host_keys.update(salt['pillar.get']('openssh:known_hosts:static', {}).items()) -%} + +{#- Loop over targetted minions -#} {%- for host, keys in host_keys| dictsort -%} -{{ known_host_entry(host, host_names, keys) }} +{{ known_host_entry(host, host_names, keys, include_localhost) }} {%- endfor -%} diff --git a/openssh/gather_host_keys.sls b/openssh/gather_host_keys.sls new file mode 100644 index 0000000..b02042a --- /dev/null +++ b/openssh/gather_host_keys.sls @@ -0,0 +1,36 @@ +{%- set minions = salt.slsutil.renderer(opts['config_dir'] + '/roster') %} +{%- set cache_dir = opts['cachedir'] + '/../master/known_hosts_salt_ssh' %} +{%- set cmd = "cat /etc/ssh/ssh_host_*_key.pub 2>/dev/null" %} + +{{ cache_dir }}: + file.directory: + - makedirs: True + +{%- for minion_id in minions %} +{%- set salt_ssh_cmd = "salt-ssh --out=json --static '{}' cmd.run_all '{}'".format(minion_id, cmd) %} +{%- set result = salt['cmd.run_all'](salt_ssh_cmd, + python_shell=True, + runas=salt['pillar.get']('openssh:known_hosts:salt_ssh:user', 'salt-master') + ) +%} + +{%- set pubkeys = False %} +{%- if result['retcode'] == 0 %} +{%- load_json as inner_result %} +{{ result['stdout'] }} +{%- endload %} +{%- set pubkeys = inner_result[minion_id]['stdout'].splitlines() | sort | join("\n") %} +{%- else %} +{%- do salt.log.error("{} failed: {}".format(salt_ssh_cmd, result)) %} +{%- endif %} + +{%- if pubkeys %} +{{ cache_dir }}/{{ minion_id }}.pub: + file.managed: + - contents: | + {{ pubkeys | indent(8) }} + - require: + - file: {{ cache_dir }} +{%- endif %} + +{%- endfor %} diff --git a/pillar.example b/pillar.example index 5f519ce..31c24f9 100644 --- a/pillar.example +++ b/pillar.example @@ -303,10 +303,26 @@ openssh: #hostnames: # Restrict wich hosts you want to use via their hostname # (i.e. ssh user@host instead of ssh user@host.example.com) - # target: '*' # Defaults to "*.{}".format(grains['domain']) with a fallback to '*' + # target: '*' # Defaults to "*.{{ grains['domain']}}" # tgt_type: 'glob' # To activate the defaults you can just set an empty dict. #hostnames: {} + # Include localhost, 127.0.0.1 and ::1 (default: False) + include_localhost: False + # Host keys fetched via salt-ssh + salt_ssh: + # The salt-ssh user + user: salt-master + # specify public host names of a minion + public_ssh_host_names: + minion.id: + - minion.id + - alias.of.minion.id + # specify public host keys of a minion + public_ssh_host_keys: + minion.id: | + ssh-rsa [...] + ssh-ed25519 [...] # Here you can list keys for hosts which are not among your minions: static: github.com: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGm[...]'