Merge pull request #44324 from benediktwerner/accept-minions-from-grains

Automatically accept minion keys based on grains
This commit is contained in:
Nicole Thomas 2017-12-08 09:02:01 -05:00 committed by GitHub
commit 95d9eb5744
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 351 additions and 11 deletions

View File

@ -36,7 +36,7 @@
# The root directory prepended to these options: pki_dir, cachedir,
# sock_dir, log_file, autosign_file, autoreject_file, extension_modules,
# key_logfile, pidfile:
# key_logfile, pidfile, autosign_grains_dir:
#root_dir: /
# The path to the master's configuration file.
@ -351,6 +351,11 @@
# the autosign_file and the auto_accept setting.
#autoreject_file: /etc/salt/autoreject.conf
# If the autosign_grains_dir is specified, incoming keys from minons with grain
# values matching those defined in files in this directory will be accepted
# automatically. This is insecure. Minions need to be configured to send the grains.
#autosign_grains_dir: /etc/salt/autosign_grains
# Enable permissive access to the salt keys. This allows you to run the
# master or minion as root, but have a non-root group be given access to
# your pki_dir. To make the access explicit, root must belong to the group
@ -1297,4 +1302,3 @@
# use OS defaults, typically 75 seconds on Linux, see
# /proc/sys/net/ipv4/tcp_keepalive_intvl.
#tcp_keepalive_intvl: -1

View File

@ -666,6 +666,12 @@
# certfile: <path_to_certfile>
# ssl_version: PROTOCOL_TLSv1_2
# Grains to be sent to the master on authentication to check if the minion's key
# will be accepted automatically. Needs to be configured on the master.
#autosign_grains:
# - uuid
# - server_id
###### Reactor Settings #####
###########################################

View File

@ -37,7 +37,7 @@ syndic_user: salt
# The root directory prepended to these options: pki_dir, cachedir,
# sock_dir, log_file, autosign_file, autoreject_file, extension_modules,
# key_logfile, pidfile:
# key_logfile, pidfile, autosign_grains_dir:
#root_dir: /
# The path to the master's configuration file.
@ -320,6 +320,11 @@ syndic_user: salt
# the autosign_file and the auto_accept setting.
#autoreject_file: /etc/salt/autoreject.conf
# If the autosign_grains_dir is specified, incoming keys from minons with grain
# values matching those defined in files in this directory will be accepted
# automatically. This is insecure. Minions need to be configured to send the grains.
#autosign_grains_dir: /etc/salt/autosign_grains
# Enable permissive access to the salt keys. This allows you to run the
# master or minion as root, but have a non-root group be given access to
# your pki_dir. To make the access explicit, root must belong to the group
@ -1248,4 +1253,3 @@ syndic_user: salt
# use OS defaults, typically 75 seconds on Linux, see
# /proc/sys/net/ipv4/tcp_keepalive_intvl.
#tcp_keepalive_intvl: -1

View File

@ -140,7 +140,8 @@ an alternative root.
This directory is prepended to the following options:
:conf_master:`pki_dir`, :conf_master:`cachedir`, :conf_master:`sock_dir`,
:conf_master:`log_file`, :conf_master:`autosign_file`,
:conf_master:`autoreject_file`, :conf_master:`pidfile`.
:conf_master:`autoreject_file`, :conf_master:`pidfile`,
:conf_master:`autosign_grains_dir`.
.. conf_master:: conf_file
@ -1321,6 +1322,32 @@ minion IDs for which keys will automatically be rejected. Will override both
membership in the :conf_master:`autosign_file` and the
:conf_master:`auto_accept` setting.
.. conf_master:: autosign_grains_dir
``autosign_grains_dir``
-----------------------
.. versionadded:: Oxygen
Default: ``not defined``
If the ``autosign_grains_dir`` is specified, incoming keys from minions with
grain values that match those defined in files in the autosign_grains_dir
will be accepted automatically. Grain values that should be accepted automatically
can be defined by creating a file named like the corresponding grain in the
autosign_grains_dir and writing the values into that file, one value per line.
Lines starting with a ``#`` will be ignored.
Minion must be configured to send the corresponding grains on authentication.
This should still be considered a less than secure option, due to the fact
that trust is based on just the requesting minion.
Please see the :ref:`Autoaccept Minions from Grains <tutorial-autoaccept-grains>`
documentation for more infomation.
.. code-block:: yaml
autosign_grains_dir: /etc/salt/autosign_grains
.. conf_master:: permissive_pki_access
``permissive_pki_access``

View File

@ -2423,6 +2423,27 @@ minion's pki directory.
master_sign_key_name: <filename_without_suffix>
.. conf_minion:: autosign_grains
``autosign_grains``
-------------------
.. versionadded:: Oxygen
Default: ``not defined``
The grains that should be sent to the master on authentication to decide if
the minion's key should be accepted automatically.
Please see the :ref:`Autoaccept Minions from Grains <tutorial-autoaccept-grains>`
documentation for more infomation.
.. code-block:: yaml
autosign_grains:
- uuid
- server_id
.. conf_minion:: always_verify_signature
``always_verify_signature``

View File

@ -0,0 +1,44 @@
.. _tutorial-autoaccept-grains:
==============================
Autoaccept minions from Grains
==============================
.. versionadded:: Oxygen
To automatically accept minions based on certain characteristics, e.g. the ``uuid``
you can specify certain grain values on the salt master. Minions with matching grains
will have their keys automatically accepted.
1. Configure the autosign_grains_dir in the master config file:
.. code-block:: yaml
autosign_grains_dir: /etc/salt/autosign_grains
2. Configure the grain values to be accepted
Place a file named like the grain in the autosign_grains_dir and write the values that
should be accepted automatically inside that file. For example to automatically
accept minions based on their ``uuid`` create a file named ``/etc/salt/autosign_grains/uuid``:
.. code-block:: none
8f7d68e2-30c5-40c6-b84a-df7e978a03ee
1d3c5473-1fbc-479e-b0c7-877705a0730f
The master is now setup to accept minions with either of the two specified uuids.
Multiple values must always be written into separate lines.
Lines starting with a ``#`` are ignored.
3. Configure the minion to send the specific grains to the master in the minion config file:
.. code-block:: yaml
autosign_grains:
- uuid
Now you should be able to start salt-minion and run ``salt-call
state.apply`` or any other salt commands that require master authentication.

View File

@ -35,3 +35,4 @@ Tutorials Index
* :ref:`Multi-cloud orchestration with Apache Libcloud <tutorial-libcloud>`
* :ref:`Running Salt States and Commands in Docker Containers <docker-sls>`
* :ref:`Preseed Minion with Accepted Key <tutorial-preseed-key>`
* :ref:`Autoaccept Minions from Grains <tutorial-autoaccept-grains>`

View File

@ -2444,7 +2444,7 @@ def syndic_config(master_config_path,
# Prepend root_dir to other paths
prepend_root_dirs = [
'pki_dir', 'key_dir', 'cachedir', 'pidfile', 'sock_dir', 'extension_modules',
'autosign_file', 'autoreject_file', 'token_dir'
'autosign_file', 'autoreject_file', 'token_dir', 'autosign_grains_dir'
]
for config_key in ('log_file', 'key_logfile', 'syndic_log_file'):
# If this is not a URI and instead a local path
@ -3852,7 +3852,7 @@ def apply_master_config(overrides=None, defaults=None):
prepend_root_dirs = [
'pki_dir', 'key_dir', 'cachedir', 'pidfile', 'sock_dir', 'extension_modules',
'autosign_file', 'autoreject_file', 'token_dir', 'syndic_dir',
'sqlite_queue_dir'
'sqlite_queue_dir', 'autosign_grains_dir'
]
# These can be set to syslog, so, not actual paths on the system

View File

@ -740,6 +740,11 @@ class AsyncAuth(object):
payload = {}
payload[u'cmd'] = u'_auth'
payload[u'id'] = self.opts[u'id']
if u'autosign_grains' in self.opts:
autosign_grains = {}
for grain in self.opts[u'autosign_grains']:
autosign_grains[grain] = self.opts[u'grains'].get(grain, None)
payload[u'autosign_grains'] = autosign_grains
try:
pubkey_path = os.path.join(self.opts[u'pki_dir'], self.mpub)
with salt.utils.files.fopen(pubkey_path) as f:

View File

@ -348,6 +348,33 @@ class AutoKey(object):
os.remove(stub_file)
return True
def check_autosign_grains(self, autosign_grains):
'''
Check for matching grains in the autosign_grains_dir.
'''
if not autosign_grains or u'autosign_grains_dir' not in self.opts:
return False
autosign_grains_dir = self.opts[u'autosign_grains_dir']
for root, dirs, filenames in os.walk(autosign_grains_dir):
for grain in filenames:
if grain in autosign_grains:
grain_file = os.path.join(autosign_grains_dir, grain)
if not self.check_permissions(grain_file):
message = 'Wrong permissions for {0}, ignoring content'
log.warning(message.format(grain_file))
continue
with salt.utils.files.fopen(grain_file, u'r') as f:
for line in f:
line = line.strip()
if line.startswith(u'#'):
continue
if autosign_grains[grain] == line:
return True
return False
def check_autoreject(self, keyid):
'''
Checks if the specified keyid should automatically be rejected.
@ -357,7 +384,7 @@ class AutoKey(object):
self.opts.get('autoreject_file', None)
)
def check_autosign(self, keyid):
def check_autosign(self, keyid, autosign_grains=None):
'''
Checks if the specified keyid should automatically be signed.
'''
@ -367,6 +394,8 @@ class AutoKey(object):
return True
if self.check_autosign_dir(keyid):
return True
if self.check_autosign_grains(autosign_grains):
return True
return False

View File

@ -211,7 +211,7 @@ class AESReqServerMixin(object):
# Check if key is configured to be auto-rejected/signed
auto_reject = self.auto_key.check_autoreject(load['id'])
auto_sign = self.auto_key.check_autosign(load['id'])
auto_sign = self.auto_key.check_autosign(load['id'], load.get(u'autosign_grains', None))
pubfn = os.path.join(self.opts['pki_dir'],
'minions',

View File

@ -838,6 +838,8 @@ class TestDaemon(object):
opts_dict['ext_pillar'].append(
{'cmd_yaml': 'cat {0}'.format(os.path.join(FILES, 'ext.yaml'))})
# all read, only owner write
autosign_file_permissions = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH | stat.S_IWUSR
for opts_dict in (master_opts, syndic_master_opts):
# We need to copy the extension modules into the new master root_dir or
# it will be prefixed by it
@ -851,6 +853,14 @@ class TestDaemon(object):
)
opts_dict['extension_modules'] = os.path.join(opts_dict['root_dir'], 'extension_modules')
# Copy the autosign_file to the new master root_dir
new_autosign_file_path = os.path.join(opts_dict['root_dir'], 'autosign_file')
shutil.copyfile(
os.path.join(INTEGRATION_TEST_DIR, 'files', 'autosign_file'),
new_autosign_file_path
)
os.chmod(new_autosign_file_path, autosign_file_permissions)
# Point the config values to the correct temporary paths
for name in ('hosts', 'aliases'):
optname = '{0}.file'.format(name)

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Import Python libs
from __future__ import absolute_import
import os
import shutil
import stat
# Import Salt Testing libs
from tests.support.case import ShellCase
from tests.support.paths import TMP, INTEGRATION_TEST_DIR
# Import 3rd-party libs
# Import Salt libs
import salt.utils.files
# all read, only owner write
autosign_file_permissions = stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH | stat.S_IWUSR
autosign_file_path = os.path.join(TMP, 'rootdir', 'autosign_file')
class AutosignGrainsTest(ShellCase):
'''
Test autosigning minions based on grain values.
'''
def setUp(self):
shutil.copyfile(
os.path.join(INTEGRATION_TEST_DIR, 'files', 'autosign_grains', 'autosign_file'),
autosign_file_path
)
os.chmod(autosign_file_path, autosign_file_permissions)
self.run_key('-d minion -y')
self.run_call('test.ping -l quiet') # get minon to try to authenticate itself again
if 'minion' in self.run_key('-l acc'):
self.tearDown()
self.skipTest('Could not deauthorize minion')
if 'minion' not in self.run_key('-l un'):
self.tearDown()
self.skipTest('minion did not try to reauthenticate itself')
self.autosign_grains_dir = os.path.join(self.master_opts['autosign_grains_dir'])
if not os.path.isdir(self.autosign_grains_dir):
os.makedirs(self.autosign_grains_dir)
def tearDown(self):
shutil.copyfile(
os.path.join(INTEGRATION_TEST_DIR, 'files', 'autosign_file'),
autosign_file_path
)
os.chmod(autosign_file_path, autosign_file_permissions)
self.run_call('test.ping -l quiet') # get minon to authenticate itself again
if os.path.isdir(self.autosign_grains_dir):
shutil.rmtree(self.autosign_grains_dir)
def test_autosign_grains_accept(self):
grain_file_path = os.path.join(self.autosign_grains_dir, 'test_grain')
with salt.utils.files.fopen(grain_file_path, 'w') as f:
f.write('#invalid_value\ncheese')
os.chmod(grain_file_path, autosign_file_permissions)
self.run_call('test.ping -l quiet') # get minon to try to authenticate itself again
self.assertIn('minion', self.run_key('-l acc'))
def test_autosign_grains_fail(self):
grain_file_path = os.path.join(self.autosign_grains_dir, 'test_grain')
with salt.utils.files.fopen(grain_file_path, 'w') as f:
f.write('#cheese\ninvalid_value')
os.chmod(grain_file_path, autosign_file_permissions)
self.run_call('test.ping -l quiet') # get minon to try to authenticate itself again
self.assertNotIn('minion', self.run_key('-l acc'))
self.assertIn('minion', self.run_key('-l un'))

View File

@ -0,0 +1,2 @@
# match everything
*

View File

@ -0,0 +1,2 @@
# match everything except 'minion'
^(?!minion$)

View File

@ -8,7 +8,6 @@ worker_threads: 3
pidfile: master.pid
sock_dir: master_sock
timeout: 12
open_mode: True
fileserver_list_cache_time: 0
file_buffer_size: 8192
file_recv: True
@ -97,3 +96,6 @@ libcloud_dns:
key: 12345
secret: mysecret
shopper_id: 12345
autosign_grains_dir: autosign_grains
autosign_file: autosign_file

View File

@ -103,3 +103,6 @@ osenv:
cmd_blacklist_glob:
- 'bad_command *'
- 'second_bad_command *'
autosign_grains:
- test_grain

View File

@ -169,6 +169,9 @@ TEST_SUITES = {
'external_api':
{'display_name': 'ExternalAPIs',
'path': 'integration/externalapi'},
'daemons':
{'display_name': 'Daemon',
'path': 'integration/daemons'},
}
@ -468,6 +471,14 @@ class SaltTestsuiteParser(SaltCoverageTestingParser):
default=False,
help='Run venafi runner tests'
)
self.test_selection_group.add_option(
'--daemons',
'--daemon-tests',
dest='daemons',
action='store_true',
default=False,
help='Run salt/daemons/*.py tests'
)
def validate_options(self):
if self.options.cloud_provider or self.options.external_api:

View File

@ -3,6 +3,7 @@
# Import Python libs
from __future__ import absolute_import
from functools import wraps
import io
import stat
# Import Salt libs
@ -58,7 +59,8 @@ class AutoKeyTest(TestCase):
'''
def setUp(self):
opts = {'user': 'test_user'}
opts = salt.config.master_config(None)
opts[u'user'] = u'test_user'
self.auto_key = masterapi.AutoKey(opts)
self.stats = {}
@ -135,6 +137,93 @@ class AutoKeyTest(TestCase):
self.stats['testfile'] = {'mode': gen_permissions('w', '', ''), 'gid': 0}
self.assertTrue(self.auto_key.check_permissions('testfile'))
def _test_check_autosign_grains(self,
test_func,
file_content=u'test_value',
file_name=u'test_grain',
autosign_grains_dir=u'test_dir',
permissions_ret=True):
'''
Helper function for testing autosign_grains().
Patches ``os.walk`` to return only ``file_name`` and ``salt.utils.files.fopen`` to open a
mock file with ``file_content`` as content. Optionally sets ``opts`` values.
Then executes test_func. The ``os.walk`` and ``salt.utils.files.fopen`` mock objects
are passed to the function as arguments.
'''
if autosign_grains_dir:
self.auto_key.opts[u'autosign_grains_dir'] = autosign_grains_dir
mock_file = io.StringIO(file_content)
mock_dirs = [(None, None, [file_name])]
with patch('os.walk', MagicMock(return_value=mock_dirs)) as mock_walk, \
patch('salt.utils.files.fopen', MagicMock(return_value=mock_file)) as mock_open, \
patch('salt.daemons.masterapi.AutoKey.check_permissions',
MagicMock(return_value=permissions_ret)) as mock_permissions:
test_func(mock_walk, mock_open, mock_permissions)
def test_check_autosign_grains_no_grains(self):
'''
Asserts that autosigning from grains fails when no grain values are passed.
'''
def test_func(mock_walk, mock_open, mock_permissions):
self.assertFalse(self.auto_key.check_autosign_grains(None))
self.assertEqual(mock_walk.call_count, 0)
self.assertEqual(mock_open.call_count, 0)
self.assertEqual(mock_permissions.call_count, 0)
self.assertFalse(self.auto_key.check_autosign_grains({}))
self.assertEqual(mock_walk.call_count, 0)
self.assertEqual(mock_open.call_count, 0)
self.assertEqual(mock_permissions.call_count, 0)
self._test_check_autosign_grains(test_func)
def test_check_autosign_grains_no_autosign_grains_dir(self):
'''
Asserts that autosigning from grains fails when the \'autosign_grains_dir\' config option
is undefined.
'''
def test_func(mock_walk, mock_open, mock_permissions):
self.assertFalse(self.auto_key.check_autosign_grains({u'test_grain': u'test_value'}))
self.assertEqual(mock_walk.call_count, 0)
self.assertEqual(mock_open.call_count, 0)
self.assertEqual(mock_permissions.call_count, 0)
self._test_check_autosign_grains(test_func, autosign_grains_dir=None)
def test_check_autosign_grains_accept(self):
'''
Asserts that autosigning from grains passes when a matching grain value is in an
autosign_grain file.
'''
def test_func(*args):
self.assertTrue(self.auto_key.check_autosign_grains({u'test_grain': u'test_value'}))
file_content = u'#test_ignore\ntest_value'
self._test_check_autosign_grains(test_func, file_content=file_content)
def test_check_autosign_grains_accept_not(self):
'''
Asserts that autosigning from grains fails when the grain value is not in the
autosign_grain files.
'''
def test_func(*args):
self.assertFalse(self.auto_key.check_autosign_grains({u'test_grain': u'test_invalid'}))
file_content = u'#test_invalid\ntest_value'
self._test_check_autosign_grains(test_func, file_content=file_content)
def test_check_autosign_grains_invalid_file_permissions(self):
'''
Asserts that autosigning from grains fails when the grain file has the wrong permissions.
'''
def test_func(*args):
self.assertFalse(self.auto_key.check_autosign_grains({u'test_grain': u'test_value'}))
file_content = u'#test_ignore\ntest_value'
self._test_check_autosign_grains(test_func, file_content=file_content, permissions_ret=False)
@skipIf(NO_MOCK, NO_MOCK_REASON)
class LocalFuncsTestCase(TestCase):