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
This commit is contained in:
alxwr 2018-06-01 14:11:52 +02:00 committed by Niels Abspoel
parent 11366b3c17
commit aa3da8f2c2
5 changed files with 273 additions and 10 deletions

View File

@ -66,12 +66,15 @@ distribution.
Manages the side-wide ssh_known_hosts file and fills it with the 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) 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`` 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 and ``openssh:known_hosts:tgt_type`` (those fields map directly to the
corresponding attributes of the ``mine.get`` function). 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 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, 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 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 mine_function: cmd.run
cmd: cat /etc/ssh/ssh_host_*_key.pub cmd: cat /etc/ssh/ssh_host_*_key.pub
python_shell: True python_shell: True
public_ssh_hostname: public_ssh_host_names:
mine_function: grains.get mine_function: grains.get
key: id 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 official hostname of a minion, the alternate DNS name will be associated to the
minion's public SSH host key. 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``:: pillar key ``openssh:known_hosts:static``::
openssh: openssh:
@ -112,6 +172,13 @@ pillar key ``openssh:known_hosts:static``::
github.com: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq[...]' github.com: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq[...]'
gitlab.com: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABA[...]' 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`` ``openssh.moduli``
----------------------- -----------------------

View File

@ -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

View File

@ -3,7 +3,7 @@
#} #}
{#- Generates one known_hosts entry per given key #} {#- 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 #} {#- Get IPv4 and IPv6 addresses from the DNS #}
{%- set ip4 = salt['dig.A'](host) -%} {%- set ip4 = salt['dig.A'](host) -%}
@ -11,7 +11,13 @@
{#- The host names to use are to be found within the dict 'host_names'. #} {#- The host names to use are to be found within the dict 'host_names'. #}
{#- If there are none, the host is used directly. #} {#- 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. #} {#- Extract the hostname from the FQDN and add it to the names. #}
{%- if use_hostnames is iterable -%} {%- if use_hostnames is iterable -%}
@ -44,7 +50,7 @@
{%- endmacro -%} {%- endmacro -%}
{#- Pre-fetch pillar data #} {#- 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 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 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') -%} {%- 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_default = '*' if grains['domain'] == '' else "*.{}".format(grains['domain']) -%}
{%- set hostnames_target = salt['pillar.get']('openssh:known_hosts:hostnames:target', hostnames_target_default) -%} {%- 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 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 {#- Lookup IP of all aliases so that when we have a matching IP, we inject the alias name
in the SSH known_hosts entry -#} in the SSH known_hosts entry -#}
@ -63,11 +70,33 @@
{%- endfor -%} {%- endfor -%}
{%- endfor -%} {%- endfor -%}
{#- Loop over targetted minions -#} {#- Salt Mine #}
{%- set host_keys = salt['mine.get'](target, keys_function, tgt_type=tgt_type) -%} {%- 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) -%} {%- 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', {%- do host_keys.update(salt['pillar.get']('openssh:known_hosts:static',
{}).items()) -%} {}).items()) -%}
{#- Loop over targetted minions -#}
{%- for host, keys in host_keys| dictsort -%} {%- for host, keys in host_keys| dictsort -%}
{{ known_host_entry(host, host_names, keys) }} {{ known_host_entry(host, host_names, keys, include_localhost) }}
{%- endfor -%} {%- endfor -%}

View File

@ -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 %}

View File

@ -303,10 +303,26 @@ openssh:
#hostnames: #hostnames:
# Restrict wich hosts you want to use via their hostname # Restrict wich hosts you want to use via their hostname
# (i.e. ssh user@host instead of ssh user@host.example.com) # (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' # tgt_type: 'glob'
# To activate the defaults you can just set an empty dict. # To activate the defaults you can just set an empty dict.
#hostnames: {} #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: # Here you can list keys for hosts which are not among your minions:
static: static:
github.com: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGm[...]' github.com: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGm[...]'