From cd09131d24a6d35f9aac33ec88c5af7dbc55520b Mon Sep 17 00:00:00 2001 From: Skyler Hawthorne Date: Tue, 14 Nov 2023 22:53:42 -0500 Subject: [PATCH] Implement client-side TLS certificates This adds the ability to use client-side TLS certificates when connecting to the salt-api server. Users can specify the required files at either the command line, environment variables, or the `.pepperrc`. --- .gitignore | 1 + README.rst | 7 +- pepper/cli.py | 145 +++++++++++++++++++++++---------------- pepper/libpepper.py | 78 +++++++++++++++++---- tests/unit/test_token.py | 54 ++++++++------- 5 files changed, 189 insertions(+), 96 deletions(-) diff --git a/.gitignore b/.gitignore index 9c9e542..48a0821 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ MANIFEST dist/ salt_pepper.egg-info/ .tox/ +.eggs/ diff --git a/README.rst b/README.rst index 17992d8..5bcdb5c 100644 --- a/README.rst +++ b/README.rst @@ -41,7 +41,7 @@ Installation Usage ----- -Basic usage is in heavy flux. You can run pepper using the script in %PYTHONHOME%/scripts/pepper (a pepper.cmd wrapper is provided for convenience to Windows users). +You can run pepper using the script in %PYTHONHOME%/scripts/pepper (a pepper.cmd wrapper is provided for convenience to Windows users). .. code-block:: bash @@ -70,6 +70,11 @@ or in a configuration file ``$HOME/.pepperrc`` with the following syntax : SALTAPI_PASS=saltdev SALTAPI_EAUTH=pam + # if you use client-side TLS certificates + SALTAPI_CA_BUNDLE=/path/to/ca-chain.cert.pem + SALTAPI_CLIENT_CERT=/path/to/client.cert.pem + SALTAPI_CLIENT_CERT_KEY=/path/to/client.key.pem + Contributing ------------ diff --git a/pepper/cli.py b/pepper/cli.py index 68901d9..48ebca6 100644 --- a/pepper/cli.py +++ b/pepper/cli.py @@ -21,26 +21,12 @@ ) -try: - # Python 3 +if sys.version_info[0] == 3: from configparser import ConfigParser, RawConfigParser -except ImportError: - # Python 2 + JSONDecodeError = json.JSONDecodeError +elif sys.version_info[0] == 2: from ConfigParser import ConfigParser, RawConfigParser - -try: - # Python 3 - JSONDecodeError = json.decode.JSONDecodeError -except AttributeError: - # Python 2 JSONDecodeError = ValueError - -try: - input = raw_input -except NameError: - pass - -if sys.version_info[0] == 2: FileNotFoundError = IOError logger = logging.getLogger(__name__) @@ -132,8 +118,48 @@ def parse(self): self.parser.add_option( '--ignore-ssl-errors', action='store_true', dest='ignore_ssl_certificate_errors', default=False, help=textwrap.dedent(''' - Ignore any SSL certificate that may be encountered. Note that it is - recommended to resolve certificate errors for production. + Ignore any SSL certificate that may be encountered. Note that + it is recommended to resolve certificate errors for production. + This option makes the `ca-bundle` flag ignored. + '''), + ) + + self.parser.add_option( + '--ca-bundle', + dest='ca_bundle', + default=None, + help=textwrap.dedent(''' + The path to a file of concatenated CA certificates in PEM + format, or a directory of such files. + '''), + ) + + self.parser.add_option( + '--client-cert', + dest='client_cert', + default=None, + help=textwrap.dedent(''' + Client side certificate to send with requests. Should be a path + to a single file in PEM format containing the certificate + as well as any number of CA certificates needed to establish + the certificate’s authenticity. + + If `--client-cert-key` is not given, this file must also contain + the private key of the client certificate. + '''), + ) + + self.parser.add_option( + '--client-cert-key', + dest='client_cert_key', + default=None, + help=textwrap.dedent(''' + Private key for the client side certificate given in + `--client-cert`. + + If `--client-cert` is given but this argument is not, then the + client cert file given with `--client-cert` must contain the + private key. '''), ) @@ -145,6 +171,9 @@ def parse(self): s = repr(toggled_options).strip("[]") self.parser.error("Options %s are mutually exclusive" % s) + if self.options.client_cert_key and not self.options.client_cert: + self.parser.error("'--client-cert-key' given without '--client-cert'") + def add_globalopts(self): ''' Misc global options @@ -377,6 +406,10 @@ def get_login_details(self): 'SALTAPI_USER': None, 'SALTAPI_PASS': None, 'SALTAPI_EAUTH': 'auto', + + 'SALTAPI_CA_BUNDLE': None, + 'SALTAPI_CLIENT_CERT': None, + 'SALTAPI_CLIENT_CERT_KEY': None, } try: @@ -396,33 +429,37 @@ def get_login_details(self): for key, value in list(results.items()): results[key] = os.environ.get(key, results[key]) - if results['SALTAPI_EAUTH'] == 'kerberos': - results['SALTAPI_PASS'] = None + ret = {} - if self.options.eauth: - results['SALTAPI_EAUTH'] = self.options.eauth - if self.options.token_expire: - results['SALTAPI_TOKEN_EXPIRE'] = self.options.token_expire - if self.options.username is None and results['SALTAPI_USER'] is None: + if not self.options.username and not results.get('SALTAPI_USER'): if self.options.interactive: - results['SALTAPI_USER'] = input('Username: ') + ret['username'] = input('Username: ') else: raise PepperAuthException("SALTAPI_USER required") else: - if self.options.username is not None: - results['SALTAPI_USER'] = self.options.username - if self.options.password is None and \ - results['SALTAPI_PASS'] is None and \ + ret['username'] = self.options.username or results["SALTAPI_USER"] + + if not self.options.password and \ + not results['SALTAPI_PASS'] and \ results['SALTAPI_EAUTH'] != 'kerberos': if self.options.interactive: - results['SALTAPI_PASS'] = getpass.getpass(prompt='Password: ') + ret['password'] = getpass.getpass(prompt='Password: ') else: raise PepperAuthException("SALTAPI_PASS required") else: - if self.options.password is not None: - results['SALTAPI_PASS'] = self.options.password + ret['password'] = self.options.password or results['SALTAPI_PASS'] + + if results['SALTAPI_EAUTH'] == 'kerberos': + ret['password'] = None - return results + ret['eauth'] = self.options.eauth or results.get('SALTAPI_EAUTH') + ret['token_expire'] = self.options.token_expire or results.get('SALTAPI_TOKEN_EXPIRE') + ret['token_expire'] = ret['token_expire'] and int(ret['token_expire']) + ret['ca_bundle'] = self.options.ca_bundle or results.get('SALTAPI_CA_BUNDLE') + ret['client_cert'] = self.options.client_cert or results.get('SALTAPI_CLIENT_CERT') + ret['client_cert_key'] = self.options.client_cert_key or results.get('SALTAPI_CLIENT_CERT_KEY') + + return ret def parse_url(self): ''' @@ -451,25 +488,6 @@ def parse_url(self): return url - def parse_login(self): - ''' - Extract the authentication credentials - ''' - login_details = self.get_login_details() - - # Auth values placeholder; grab interactively at CLI or from config - username = login_details['SALTAPI_USER'] - password = login_details['SALTAPI_PASS'] - eauth = login_details['SALTAPI_EAUTH'] - - ret = dict(username=username, password=password, eauth=eauth) - - token_expire = login_details.get('SALTAPI_TOKEN_EXPIRE', None) - if token_expire: - ret['token_expire'] = int(token_expire) - - return ret - def parse_cmd(self, api): ''' Extract the low data for a command from the passed CLI params @@ -604,7 +622,7 @@ def poll_for_returns(self, api, load): if failed: yield exit_code, [{'Failed': failed}] - def login(self, api): + def login(self, api, login_details): login = api.token if self.options.userun else api.login if self.options.mktoken: @@ -612,7 +630,7 @@ def login(self, api): try: with open(token_file, 'rt') as f: auth = json.load(f) - if auth['expire'] < time.time()+30: + if auth['expire'] < time.time() + 30: logger.error('Login token expired') raise Exception('Login token expired') except Exception as e: @@ -620,7 +638,8 @@ def login(self, api): logger.error('Unable to load login token from {0} {1}'.format(token_file, str(e))) if os.path.isfile(token_file): os.remove(token_file) - auth = login(**self.parse_login()) + auth = login(**login_details) + try: oldumask = os.umask(0) fdsc = os.open(token_file, os.O_WRONLY | os.O_CREAT, 0o600) @@ -631,7 +650,7 @@ def login(self, api): finally: os.umask(oldumask) else: - auth = login(**self.parse_login()) + auth = login(**login_details) api.auth = auth self.auth = auth @@ -662,12 +681,18 @@ def run(self): rootLogger.addHandler(logging.StreamHandler()) rootLogger.setLevel(max(logging.ERROR - (self.options.verbose * 10), 1)) + login_details = self.get_login_details() + api = pepper.Pepper( self.parse_url(), debug_http=self.options.debug_http, - ignore_ssl_errors=self.options.ignore_ssl_certificate_errors) + ignore_ssl_errors=self.options.ignore_ssl_certificate_errors, + ca_bundle=login_details.get("ca_bundle"), + client_cert=login_details.get("client_cert"), + client_cert_key=login_details.get("client_cert_key"), + ) - self.login(api) + self.login(api, login_details) load = self.parse_cmd(api) diff --git a/pepper/libpepper.py b/pepper/libpepper.py index c9ab461..e62f506 100644 --- a/pepper/libpepper.py +++ b/pepper/libpepper.py @@ -6,22 +6,20 @@ ''' import json import logging +import os import re import ssl +import sys from pepper.exceptions import PepperException -try: - ssl._create_default_https_context = ssl._create_stdlib_context -except Exception: - pass - -try: +if sys.version_info[0] == 3: from urllib.request import HTTPHandler, HTTPSHandler, Request, urlopen, \ install_opener, build_opener from urllib.error import HTTPError, URLError import urllib.parse as urlparse -except ImportError: +elif sys.version_info[0] == 2: + ssl._create_default_https_context = ssl._create_stdlib_context # type: ignore[attr-defined] from urllib2 import HTTPHandler, HTTPSHandler, Request, urlopen, install_opener, build_opener, \ HTTPError, URLError import urlparse @@ -57,7 +55,16 @@ class Pepper(object): u'ms-4': True}]} ''' - def __init__(self, api_url='https://localhost:8000', debug_http=False, ignore_ssl_errors=False): + + def __init__( + self, + api_url="https://localhost:8000", + debug_http=False, + ignore_ssl_errors=False, + ca_bundle=None, + client_cert=None, + client_cert_key=None, + ): ''' Initialize the class with the URL of the API @@ -82,6 +89,28 @@ def __init__(self, api_url='https://localhost:8000', debug_http=False, ignore_ss self.auth = {} self.salt_version = None + self._ca_bundle = ca_bundle + self._client_cert = client_cert + self._client_cert_key = client_cert_key + + def _get_requests_verify(self): + if not self._ssl_verify: + verify = False + elif self._ca_bundle: + verify = self._ca_bundle + + return verify + + def _get_requests_client_cert(self): + if self._client_cert and self._client_cert_key: + cert = (self._client_cert, self._client_cert_key) + elif self._client_cert: + cert = self._client_cert + else: + cert = None + + return cert + def req_stream(self, path): ''' A thin wrapper to get a response from saltstack api. @@ -109,9 +138,13 @@ def req_stream(self, path): else: raise PepperException('Authentication required') return + + (verify, cert) = (self._get_requests_verify(), self._get_requests_client_cert()) + params = {'url': self._construct_url(path), 'headers': headers, - 'verify': self._ssl_verify is True, + 'verify': verify, + 'cert': cert, 'stream': True } try: @@ -152,9 +185,13 @@ def req_get(self, path): else: raise PepperException('Authentication required') return + + (verify, cert) = (self._get_requests_verify(), self._get_requests_client_cert()) + params = {'url': self._construct_url(path), 'headers': headers, - 'verify': self._ssl_verify is True, + 'verify': verify, + 'cert': cert, } try: resp = requests.get(**params) @@ -228,7 +265,23 @@ def req(self, path, data=None): con = ssl.SSLContext(ssl.PROTOCOL_SSLv23) f = urlopen(req, context=con) else: - f = urlopen(req) + con = ssl.create_default_context() + + if self._ca_bundle: + if os.path.isdir(self._ca_bundle): + ca_file = None + ca_path = self._ca_bundle + else: + ca_file = self._ca_bundle + ca_path = None + + con.load_verify_locations(ca_file, ca_path) + + if self._client_cert: + con.load_cert_chain(self._client_cert, self._client_cert_key) + + f = urlopen(req, context=con) + content = f.read().decode('utf-8') if (self.debug_http): logger.debug('Response: %s', content) @@ -279,7 +332,8 @@ def req_requests(self, path, data=None): # Optionally toggle SSL verification params = {'url': self._construct_url(path), 'headers': headers, - 'verify': self._ssl_verify is True, + 'verify': self._ssl_verify, + 'cert': self._client_cert, 'auth': auth, 'data': json.dumps(data), } diff --git a/tests/unit/test_token.py b/tests/unit/test_token.py index ec89946..7ad98fa 100644 --- a/tests/unit/test_token.py +++ b/tests/unit/test_token.py @@ -1,36 +1,44 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import + import json import sys +# Import Testing Libraries +from mock import MagicMock, mock_open, patch + # Import Pepper Libraries import pepper.cli -# Import Testing Libraries -from mock import patch, mock_open, MagicMock - def test_token(): - sys.argv = ['pepper', '*', 'test.ping'] + sys.argv = ["pepper", "*", "test.ping"] client = pepper.cli.PepperCli() client.options.mktoken = True - mock_data = ( - '{"perms": [".*", "@runner", "@wheel", "@jobs"], "start": 1529967752.516165, ' - '"token": "7130faa1e17f935d5f2702465cafdc73212d64d0", "expire": 1529968905.1131861, ' - '"user": "pepper", "eauth": "pam"}\n' - ) + mock_data = { + "perms": [".*", "@runner", "@wheel", "@jobs"], + "start": 1529967752.516165, + "token": "7130faa1e17f935d5f2702465cafdc73212d64d0", + "expire": 1529968905.1131861, + "user": "pepper", + "eauth": "pam", + } + mock_data_json = json.dumps(mock_data) + mock_api = MagicMock() - mock_api.login = MagicMock(return_value=mock_data) - with patch('pepper.cli.open', mock_open(read_data=mock_data)), \ - patch('pepper.cli.PepperCli.get_login_details', MagicMock(return_value=mock_data)), \ - patch('pepper.cli.PepperCli.parse_login', MagicMock(return_value={})), \ - patch('os.remove', MagicMock(return_value=None)), \ - patch('json.dump', MagicMock(side_effect=Exception('Test Error'))): - ret1 = client.login(mock_api) - with patch('os.path.isfile', MagicMock(return_value=False)): - ret2 = client.login(mock_api) - with patch('time.time', MagicMock(return_value=1529968044.133632)): - ret3 = client.login(mock_api) - assert json.loads(ret1) == json.loads(mock_data) - assert json.loads(ret2) == json.loads(mock_data) - assert ret3 == json.loads(mock_data) + mock_api.login = MagicMock(return_value=mock_data_json) + + with patch("pepper.cli.open", mock_open(read_data=mock_data_json)), patch( + "pepper.cli.PepperCli.get_login_details", MagicMock(return_value=mock_data) + ), patch("os.remove", MagicMock(return_value=None)), patch( + "json.dump", MagicMock(side_effect=Exception("Test Error")) + ): + ret1 = client.login(mock_api, mock_data) + with patch("os.path.isfile", MagicMock(return_value=False)): + ret2 = client.login(mock_api, mock_data) + with patch("time.time", MagicMock(return_value=1529968044.133632)): + ret3 = client.login(mock_api, mock_data) + + assert json.loads(ret1) == mock_data + assert json.loads(ret2) == mock_data + assert ret3 == mock_data