Skip to content

Commit

Permalink
Implement client-side TLS certificates
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
dead10ck committed Aug 13, 2024
1 parent 8ab18e8 commit cd09131
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 96 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ MANIFEST
dist/
salt_pepper.egg-info/
.tox/
.eggs/
7 changes: 6 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
------------

Expand Down
145 changes: 85 additions & 60 deletions pepper/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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.
'''),
)

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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):
'''
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -604,23 +622,24 @@ 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:
token_file = self.options.cache
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:
if e.args[0] != 2:
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)
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Loading

0 comments on commit cd09131

Please sign in to comment.