From 531ce6d3ad16c86cb9fd31bdace05ba3e6d63be8 Mon Sep 17 00:00:00 2001 From: Alexander Weidinger Date: Mon, 30 Apr 2018 21:36:27 +0200 Subject: [PATCH] Pillar openssh.known_hosts_salt_ssh --- README.rst | 57 +++++++++++++++- _pillar/known_hosts_salt_ssh.sls | 113 +++++++++++++++++++++++++++++++ openssh/files/ssh_known_hosts | 26 ++++++- openssh/gather_host_keys | 40 +++++++++++ openssh/gather_host_keys.sls | 36 ++++++++++ pillar.example | 2 +- 6 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 _pillar/known_hosts_salt_ssh.sls create mode 100644 openssh/gather_host_keys create mode 100644 openssh/gather_host_keys.sls diff --git a/README.rst b/README.rst index 5ace6a7..40844b9 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 @@ -103,7 +106,54 @@ 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 + +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 +162,7 @@ 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``. ``openssh.moduli`` ----------------------- diff --git a/_pillar/known_hosts_salt_ssh.sls b/_pillar/known_hosts_salt_ssh.sls new file mode 100644 index 0000000..a4050ab --- /dev/null +++ b/_pillar/known_hosts_salt_ssh.sls @@ -0,0 +1,113 @@ +#!py + +import logging as log +import os.path +import re +import subprocess + +cache = {} +ssh_key_pattern = re.compile("^[^ ]+ (ssh-.+)$") + +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(): + config = { + 'public_ssh_host_keys': {}, + 'public_ssh_hostname': {} + } + for minion in minions().keys(): + config['public_ssh_hostname'][minion] = minion + config['public_ssh_host_keys'][minion] = host_keys(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..7fee8ba 100644 --- a/openssh/files/ssh_known_hosts +++ b/openssh/files/ssh_known_hosts @@ -44,7 +44,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') -%} @@ -63,11 +63,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_name 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_name}) %} +{%- 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) }} {%- endfor -%} diff --git a/openssh/gather_host_keys b/openssh/gather_host_keys new file mode 100644 index 0000000..4f66050 --- /dev/null +++ b/openssh/gather_host_keys @@ -0,0 +1,40 @@ +{%- set minions = salt.slsutil.renderer(opts['config_dir'] + '/roster') %} +{%- set cache_dir = opts['cachedir'] + '/known_hosts_salt_ssh' %} +{%- set cmd = "cat /etc/ssh/ssh_host_*_key.pub 2>/dev/null | sort" %} + +{{ cache_dir }}: + file.directory: + - makedirs: True + +{%- for minion_id in minions %} +{%- if loop.first %} +{%- 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') + ) +%} + +{{ result }} + +{%- set pubkeys = False %} +{%- if result[minion_id]['retcode'] == 0 %} +{% load_json as inner_result %} +{{ result[minion_id]['stdout'] }} +{%- endload %} +{%- set pubkeys = inner_result['stdout'] %} +{%- 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 %} + +{%- endif %} +{%- 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..3603dd7 100644 --- a/pillar.example +++ b/pillar.example @@ -303,7 +303,7 @@ 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: {}