Skip to content

Commit 7fe9f7b

Browse files
committed
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`.
1 parent 8ab18e8 commit 7fe9f7b

File tree

4 files changed

+158
-73
lines changed

4 files changed

+158
-73
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ MANIFEST
77
dist/
88
salt_pepper.egg-info/
99
.tox/
10+
.eggs/

README.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Installation
4141
Usage
4242
-----
4343

44-
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).
44+
You can run pepper using the script in %PYTHONHOME%/scripts/pepper (a pepper.cmd wrapper is provided for convenience to Windows users).
4545

4646
.. code-block:: bash
4747
@@ -70,6 +70,11 @@ or in a configuration file ``$HOME/.pepperrc`` with the following syntax :
7070
SALTAPI_PASS=saltdev
7171
SALTAPI_EAUTH=pam
7272
73+
# if you use client-side TLS certificates
74+
SALTAPI_CA_BUNDLE=/path/to/ca-chain.cert.pem
75+
SALTAPI_CLIENT_CERT=/path/to/client.cert.pem
76+
SALTAPI_CLIENT_CERT_KEY=/path/to/client.key.pem
77+
7378
Contributing
7479
------------
7580

pepper/cli.py

Lines changed: 85 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,12 @@
2121
)
2222

2323

24-
try:
25-
# Python 3
24+
if sys.version_info[0] == 3:
2625
from configparser import ConfigParser, RawConfigParser
27-
except ImportError:
28-
# Python 2
26+
JSONDecodeError = json.JSONDecodeError
27+
elif sys.version_info[0] == 2:
2928
from ConfigParser import ConfigParser, RawConfigParser
30-
31-
try:
32-
# Python 3
33-
JSONDecodeError = json.decode.JSONDecodeError
34-
except AttributeError:
35-
# Python 2
3629
JSONDecodeError = ValueError
37-
38-
try:
39-
input = raw_input
40-
except NameError:
41-
pass
42-
43-
if sys.version_info[0] == 2:
4430
FileNotFoundError = IOError
4531

4632
logger = logging.getLogger(__name__)
@@ -132,8 +118,48 @@ def parse(self):
132118
self.parser.add_option(
133119
'--ignore-ssl-errors', action='store_true', dest='ignore_ssl_certificate_errors', default=False,
134120
help=textwrap.dedent('''
135-
Ignore any SSL certificate that may be encountered. Note that it is
136-
recommended to resolve certificate errors for production.
121+
Ignore any SSL certificate that may be encountered. Note that
122+
it is recommended to resolve certificate errors for production.
123+
This option makes the `ca-bundle` flag ignored.
124+
'''),
125+
)
126+
127+
self.parser.add_option(
128+
'--ca-bundle',
129+
dest='ca_bundle',
130+
default=None,
131+
help=textwrap.dedent('''
132+
The path to a file of concatenated CA certificates in PEM
133+
format, or a directory of such files.
134+
'''),
135+
)
136+
137+
self.parser.add_option(
138+
'--client-cert',
139+
dest='client_cert',
140+
default=None,
141+
help=textwrap.dedent('''
142+
Client side certificate to send with requests. Should be a path
143+
to a single file in PEM format containing the certificate
144+
as well as any number of CA certificates needed to establish
145+
the certificate’s authenticity.
146+
147+
If `--client-cert-key` is not given, this file must also contain
148+
the private key of the client certificate.
149+
'''),
150+
)
151+
152+
self.parser.add_option(
153+
'--client-cert-key',
154+
dest='client_cert_key',
155+
default=None,
156+
help=textwrap.dedent('''
157+
Private key for the client side certificate given in
158+
`--client-cert`.
159+
160+
If `--client-cert` is given but this argument is not, then the
161+
client cert file given with `--client-cert` must contain the
162+
private key.
137163
'''),
138164
)
139165

@@ -145,6 +171,9 @@ def parse(self):
145171
s = repr(toggled_options).strip("[]")
146172
self.parser.error("Options %s are mutually exclusive" % s)
147173

174+
if self.options.client_cert_key and not self.options.client_cert:
175+
self.parser.error("'--client-cert-key' given without '--client-cert'")
176+
148177
def add_globalopts(self):
149178
'''
150179
Misc global options
@@ -377,6 +406,10 @@ def get_login_details(self):
377406
'SALTAPI_USER': None,
378407
'SALTAPI_PASS': None,
379408
'SALTAPI_EAUTH': 'auto',
409+
410+
'SALTAPI_CA_BUNDLE': None,
411+
'SALTAPI_CLIENT_CERT': None,
412+
'SALTAPI_CLIENT_CERT_KEY': None,
380413
}
381414

382415
try:
@@ -396,33 +429,37 @@ def get_login_details(self):
396429
for key, value in list(results.items()):
397430
results[key] = os.environ.get(key, results[key])
398431

399-
if results['SALTAPI_EAUTH'] == 'kerberos':
400-
results['SALTAPI_PASS'] = None
432+
ret = {}
401433

402-
if self.options.eauth:
403-
results['SALTAPI_EAUTH'] = self.options.eauth
404-
if self.options.token_expire:
405-
results['SALTAPI_TOKEN_EXPIRE'] = self.options.token_expire
406-
if self.options.username is None and results['SALTAPI_USER'] is None:
434+
if not self.options.username and not results.get('SALTAPI_USER'):
407435
if self.options.interactive:
408-
results['SALTAPI_USER'] = input('Username: ')
436+
ret['username'] = input('Username: ')
409437
else:
410438
raise PepperAuthException("SALTAPI_USER required")
411439
else:
412-
if self.options.username is not None:
413-
results['SALTAPI_USER'] = self.options.username
414-
if self.options.password is None and \
415-
results['SALTAPI_PASS'] is None and \
440+
ret['username'] = self.options.username or results["SALTAPI_USER"]
441+
442+
if not self.options.password and \
443+
not results['SALTAPI_PASS'] and \
416444
results['SALTAPI_EAUTH'] != 'kerberos':
417445
if self.options.interactive:
418-
results['SALTAPI_PASS'] = getpass.getpass(prompt='Password: ')
446+
ret['password'] = getpass.getpass(prompt='Password: ')
419447
else:
420448
raise PepperAuthException("SALTAPI_PASS required")
421449
else:
422-
if self.options.password is not None:
423-
results['SALTAPI_PASS'] = self.options.password
450+
ret['password'] = self.options.password or results['SALTAPI_PASS']
451+
452+
if results['SALTAPI_EAUTH'] == 'kerberos':
453+
ret['password'] = None
424454

425-
return results
455+
ret['eauth'] = self.options.eauth or results.get('SALTAPI_EAUTH')
456+
ret['token_expire'] = self.options.token_expire or results.get('SALTAPI_TOKEN_EXPIRE')
457+
ret['token_expire'] = ret['token_expire'] and int(ret['token_expire'])
458+
ret['ca_bundle'] = self.options.ca_bundle or results.get('SALTAPI_CA_BUNDLE')
459+
ret['client_cert'] = self.options.client_cert or results.get('SALTAPI_CLIENT_CERT')
460+
ret['client_cert_key'] = self.options.client_cert_key or results.get('SALTAPI_CLIENT_CERT_KEY')
461+
462+
return ret
426463

427464
def parse_url(self):
428465
'''
@@ -451,25 +488,6 @@ def parse_url(self):
451488

452489
return url
453490

454-
def parse_login(self):
455-
'''
456-
Extract the authentication credentials
457-
'''
458-
login_details = self.get_login_details()
459-
460-
# Auth values placeholder; grab interactively at CLI or from config
461-
username = login_details['SALTAPI_USER']
462-
password = login_details['SALTAPI_PASS']
463-
eauth = login_details['SALTAPI_EAUTH']
464-
465-
ret = dict(username=username, password=password, eauth=eauth)
466-
467-
token_expire = login_details.get('SALTAPI_TOKEN_EXPIRE', None)
468-
if token_expire:
469-
ret['token_expire'] = int(token_expire)
470-
471-
return ret
472-
473491
def parse_cmd(self, api):
474492
'''
475493
Extract the low data for a command from the passed CLI params
@@ -604,23 +622,24 @@ def poll_for_returns(self, api, load):
604622
if failed:
605623
yield exit_code, [{'Failed': failed}]
606624

607-
def login(self, api):
625+
def login(self, api, login_details):
608626
login = api.token if self.options.userun else api.login
609627

610628
if self.options.mktoken:
611629
token_file = self.options.cache
612630
try:
613631
with open(token_file, 'rt') as f:
614632
auth = json.load(f)
615-
if auth['expire'] < time.time()+30:
633+
if auth['expire'] < time.time() + 30:
616634
logger.error('Login token expired')
617635
raise Exception('Login token expired')
618636
except Exception as e:
619637
if e.args[0] != 2:
620638
logger.error('Unable to load login token from {0} {1}'.format(token_file, str(e)))
621639
if os.path.isfile(token_file):
622640
os.remove(token_file)
623-
auth = login(**self.parse_login())
641+
auth = login(**login_details)
642+
624643
try:
625644
oldumask = os.umask(0)
626645
fdsc = os.open(token_file, os.O_WRONLY | os.O_CREAT, 0o600)
@@ -631,7 +650,7 @@ def login(self, api):
631650
finally:
632651
os.umask(oldumask)
633652
else:
634-
auth = login(**self.parse_login())
653+
auth = login(**login_details)
635654

636655
api.auth = auth
637656
self.auth = auth
@@ -662,12 +681,18 @@ def run(self):
662681
rootLogger.addHandler(logging.StreamHandler())
663682
rootLogger.setLevel(max(logging.ERROR - (self.options.verbose * 10), 1))
664683

684+
login_details = self.get_login_details()
685+
665686
api = pepper.Pepper(
666687
self.parse_url(),
667688
debug_http=self.options.debug_http,
668-
ignore_ssl_errors=self.options.ignore_ssl_certificate_errors)
689+
ignore_ssl_errors=self.options.ignore_ssl_certificate_errors,
690+
ca_bundle=login_details.get("ca_bundle"),
691+
client_cert=login_details.get("client_cert"),
692+
client_cert_key=login_details.get("client_cert_key"),
693+
)
669694

670-
self.login(api)
695+
self.login(api, login_details)
671696

672697
load = self.parse_cmd(api)
673698

0 commit comments

Comments
 (0)