Skip to content

Commit abc263d

Browse files
committed
Address review comments and improve auth
Auth now chooses between cert (#1) and bearer token (#2) instead of sending both. Also renamed ssl --> tls for correctness
1 parent a35be92 commit abc263d

File tree

4 files changed

+85
-83
lines changed

4 files changed

+85
-83
lines changed

checks.d/kubernetes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def _perform_kubelet_checks(self, url):
103103
service_check_base = NAMESPACE + '.kubelet.check'
104104
is_ok = True
105105
try:
106-
req = self.kubeutil.retrieve_kubelet_url(url)
106+
req = self.kubeutil.perform_kubelet_query(url)
107107
for line in req.iter_lines():
108108

109109
# avoid noise; this check is expected to fail since we override the container hostname

conf.d/kubernetes.yaml.example

+12-9
Original file line numberDiff line numberDiff line change
@@ -54,29 +54,32 @@ instances:
5454
# kubelet port. It needs to be set if you are not using a default one (10250 or 10255)
5555
# kubelet_port: 10255
5656
#
57-
# SSL configuration for querying kubelet.
57+
# TLS configuration for querying kubelet.
5858
#
5959
# Available options are:
60-
# - simple SSL validation (with readily available certificates)
61-
# - disabling SSL validation
60+
# (server auth)
61+
# - simple TLS validation (with readily available certificates)
62+
# - disabling TLS validation
6263
# - providing the server certificate
64+
# (client auth)
6365
# - providing a client cert/key pair
66+
# - using a service account bearer token
6467
#
65-
# The default is to try and use the read-only port that doesn't require SSL
66-
# And to fall back to the HTTPS API with simple SSL validation.
68+
# The default is to try and use the read-only port that doesn't require TLS
69+
# And to fall back to the HTTPS API with simple TLS validation.
6770
#
68-
# Explicitly disabling ssl_verify should be used with caution:
71+
# Explicitly disabling tls_verify should be used with caution:
6972
# if an attacker sniffs the agent requests they will see the agent's service account bearer token.
7073
#
71-
# Providing a server cert enables SSL validation.
74+
# Providing a server cert enables TLS validation.
7275
#
7376
# It is possible to providing a client cert/key pair for client auth.
74-
# The agent also uses its pod bearer token (if available) when querying kubelet and the apiserver.
77+
# If no such cert is passed, the agent authenticates using its pod bearer token (if available) when querying kubelet and the apiserver.
7578
#
7679
# The recommended way to pass certificates and keys to the agent is by using
7780
# "Secret" Kubernetes object (and to mount them as volumes).
7881
#
79-
# kubelet_ssl_verify: True
82+
# kubelet_tls_verify: True
8083
# kubelet_cert: /path/to/ca.pem
8184
# kubelet_client_crt: /path/to/client.crt
8285
# kubelet_client_key: /path/to/client.key

tests/checks/mock/test_kubernetes.py

+27-33
Original file line numberDiff line numberDiff line change
@@ -385,21 +385,22 @@ class TestKubeutil(unittest.TestCase):
385385
def setUp(self, _locate_kubelet):
386386
self.kubeutil = KubeUtil()
387387

388-
def test_init_ssl_settings(self):
388+
@mock.patch('os.path.exists', return_value=True)
389+
def test_init_tls_settings(self, *args):
389390
instances = [
390391
# (instance, expected_result)
391392
({}, {'verify': True}),
392-
({'kubelet_ssl_verify': False}, {'verify': False}),
393-
({'kubelet_ssl_verify': True}, {'verify': True}),
394-
({'kubelet_ssl_verify': 'foo.pem'}, {'verify': 'foo.pem'}),
393+
({'kubelet_tls_verify': False}, {'verify': False}),
394+
({'kubelet_tls_verify': True}, {'verify': True}),
395+
({'kubelet_tls_verify': 'foo.pem'}, {'verify': 'foo.pem'}),
395396
({'kubelet_cert': 'foo.pem'}, {'verify': 'foo.pem'}),
396397
({'kubelet_client_crt': 'client.crt', 'kubelet_client_key': 'client.key'},
397398
{'verify': True, 'kubelet_client_cert': ('client.crt', 'client.key')}),
398-
({'kubelet_ssl_verify': True, 'kubelet_client_crt': 'client.crt'}, {'verify': True}),
399+
({'kubelet_tls_verify': True, 'kubelet_client_crt': 'client.crt'}, {'verify': True}),
399400
({'kubelet_client_crt': 'client.crt'}, {'verify': True})
400401
]
401402
for instance, result in instances:
402-
self.assertEqual(self.kubeutil._init_ssl_settings(instance), result)
403+
self.assertEqual(self.kubeutil._init_tls_settings(instance), result)
403404

404405

405406
##### Test _locate_kubelet #####
@@ -419,7 +420,7 @@ def test_locate_kubelet_no_auth_no_ssl(self, _get_hostname):
419420
({'kubelet_port': '1337'}, 'http://test_docker_host:1337'),
420421
({'host': 'test_explicit_host', 'kubelet_port': '1337'}, 'http://test_explicit_host:1337')
421422
]
422-
with mock.patch('utils.kubernetes.kubeutil.KubeUtil.retrieve_kubelet_url', return_value=True):
423+
with mock.patch('utils.kubernetes.kubeutil.KubeUtil.perform_kubelet_query', return_value=True):
423424
for instance, result in no_auth_no_ssl_instances:
424425
self.assertEqual(self.kubeutil._locate_kubelet(instance), result)
425426

@@ -434,13 +435,13 @@ def test_locate_kubelet_no_auth_no_verify(self, _get_hostname):
434435
]
435436

436437
def side_effect(url):
437-
"""Mock KubeUtil.retrieve_kubelet_url"""
438+
"""Mock KubeUtil.perform_kubelet_query"""
438439
if url.startswith('https://'):
439440
return True
440441
else:
441442
raise Exception()
442443

443-
with mock.patch('utils.kubernetes.kubeutil.KubeUtil.retrieve_kubelet_url', side_effect=side_effect):
444+
with mock.patch('utils.kubernetes.kubeutil.KubeUtil.perform_kubelet_query', side_effect=side_effect):
444445
for instance, result in no_auth_no_verify_instances:
445446
self.assertEqual(self.kubeutil._locate_kubelet(instance), result)
446447

@@ -449,10 +450,10 @@ def side_effect(url):
449450
@mock.patch('utils.kubernetes.kubeutil.KubeUtil.get_auth_token', return_value='foo')
450451
def test_locate_kubelet_verify_and_auth(self, *args):
451452
"""
452-
Test kubelet connection with SSL. Also look for auth token.
453+
Test kubelet connection with TLS. Also look for auth token.
453454
"""
454455
no_auth_instances = [
455-
# instance, ssl_settings, expected_result
456+
# instance, tls_settings, expected_result
456457
({}, {'verify': True}, 'https://test_k8s_host:10250'),
457458
({'kubelet_port': '1337'}, {'verify': 'test.pem'}, 'https://test_k8s_host:1337'),
458459
(
@@ -468,46 +469,39 @@ def test_locate_kubelet_verify_and_auth(self, *args):
468469
]
469470

470471
def side_effect(url, **kwargs):
471-
"""Mock KubeUtil.retrieve_kubelet_url"""
472+
"""Mock KubeUtil.perform_kubelet_query"""
472473
if url.startswith('https://') and '10255' not in url:
473474
return True
474475
else:
475476
raise Exception()
476477

477-
# no auth / SSL with verify
478-
for instance, ssl_settings, result in no_auth_instances:
478+
# no auth / TLS with verify
479+
for instance, tls_settings, result in no_auth_instances:
479480
with mock.patch('utils.kubernetes.kubeutil.requests') as req:
480481
req.get = mock.MagicMock(side_effect=side_effect)
481-
self.kubeutil.ssl_settings = ssl_settings
482+
self.kubeutil.tls_settings = tls_settings
482483
self.assertEqual(self.kubeutil._locate_kubelet(instance), result)
483484
req.get.assert_called_with(result + '/healthz', # test endpoint
484485
timeout=10,
485-
verify=ssl_settings.get('verify', False),
486-
cert=ssl_settings.get('kubelet_client_cert'),
487-
headers={'Authorization': 'Bearer foo'}, # auth
486+
verify=tls_settings.get('verify', False),
487+
headers={'Authorization': 'Bearer foo'} if 'kubelet_client_cert' not in tls_settings else None,
488+
cert=tls_settings.get('kubelet_client_cert'),
488489
params={'verbose': True}
489490
)
490491

491492
@mock.patch('utils.kubernetes.kubeutil.KubeUtil.get_auth_token', return_value='foo')
492493
def test_get_node_hostname(self, _get_auth_tkn):
493494
node_lists = [
494495
(json.loads(Fixtures.read_file('filtered_node_list_1_4.json', string_escape=False)), 'ip-10-0-0-179'),
495-
({'items': [{'foo': 'bar'}]}, None)
496-
]
497-
498-
exception_node_lists = [
499-
{'items': []},
500-
{'items': [{'foo': 'bar'}, {'bar': 'foo'}]}
496+
({'items': [{'foo': 'bar'}]}, None),
497+
({'items': []}, None),
498+
({'items': [{'foo': 'bar'}, {'bar': 'foo'}]}, None)
501499
]
502500

503501
for node_list, expected_result in node_lists:
504502
with mock.patch('utils.kubernetes.kubeutil.KubeUtil.retrieve_json_auth', return_value=node_list):
505503
self.assertEqual(self.kubeutil.get_node_hostname('ip-10-0-0-179'), expected_result)
506504

507-
for node_list in exception_node_lists:
508-
with mock.patch('utils.kubernetes.kubeutil.KubeUtil.retrieve_json_auth', return_value=node_list):
509-
self.assertRaises(Exception, self.kubeutil.get_node_hostname, 'ip-10-0-0-179')
510-
511505
@mock.patch('utils.kubernetes.KubeUtil.retrieve_pods_list', side_effect=['foo'])
512506
@mock.patch('utils.kubernetes.KubeUtil.extract_kube_labels')
513507
def test_get_kube_labels(self, extract_kube_labels, retrieve_pods_list):
@@ -538,7 +532,7 @@ def test_extract_kube_labels(self):
538532
labels = set(inn for out in res.values() for inn in out)
539533
self.assertEqual(len(labels), 3)
540534

541-
@mock.patch('utils.kubernetes.kubeutil.KubeUtil.retrieve_kubelet_url')
535+
@mock.patch('utils.kubernetes.kubeutil.KubeUtil.perform_kubelet_query')
542536
def test_retrieve_pods_list(self, retrieve_url):
543537
self.kubeutil.retrieve_pods_list()
544538
retrieve_url.assert_called_twice_with(self.kubeutil.pods_list_url, verbose=True, timeout=10)
@@ -555,7 +549,7 @@ def test_retrieve_metrics(self, retrieve_json):
555549

556550
@mock.patch('utils.kubernetes.kubeutil.KubeUtil.get_auth_token', return_value='foo')
557551
@mock.patch('utils.kubernetes.kubeutil.requests')
558-
def test_retrieve_kubelet_url(self, req, _get_auth_tkn):
552+
def test_perform_kubelet_query(self, req, _get_auth_tkn):
559553
base_params = {'timeout': 10, 'verify': False,
560554
'params': {'verbose': True}, 'cert': None, 'headers': None}
561555

@@ -570,12 +564,12 @@ def test_retrieve_kubelet_url(self, req, _get_auth_tkn):
570564
('https://test.com', {'verify': True}, dict(base_params.items() + verify_true.items() + auth_token_header.items())),
571565
('https://test.com', {'verify': 'kubelet.pem'}, dict(base_params.items() + verify_cert.items() + auth_token_header.items())),
572566
('https://test.com', {'kubelet_client_cert': ('client.crt', 'client.key')},
573-
dict(base_params.items() + verify_true.items() + client_cert.items() + auth_token_header.items())),
567+
dict(base_params.items() + verify_true.items() + client_cert.items())),
574568
]
575569
for url, ssl_context, expected_params in instances:
576570
req.get.reset_mock()
577-
self.kubeutil.ssl_settings = ssl_context
578-
self.kubeutil.retrieve_kubelet_url(url)
571+
self.kubeutil.tls_settings = ssl_context
572+
self.kubeutil.perform_kubelet_query(url)
579573
req.get.assert_called_with(url, **expected_params)
580574

581575
@mock.patch('utils.kubernetes.kubeutil.requests')

utils/kubernetes/kubeutil.py

+45-40
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88
import os
99
from urlparse import urljoin
10+
from urllib import urlencode
1011

1112
# project
1213
from util import check_yaml
@@ -21,7 +22,7 @@
2122

2223
KUBERNETES_CHECK_NAME = 'kubernetes'
2324

24-
DEFAULT_SSL_VERIFY = True
25+
DEFAULT_TLS_VERIFY = True
2526

2627

2728
class KubeUtil:
@@ -67,11 +68,15 @@ def __init__(self, instance=None):
6768
self.kubernetes_api_url = 'https://%s/api/v1' % (os.environ.get('KUBERNETES_SERVICE_HOST') or self.DEFAULT_MASTER_NAME)
6869

6970
# kubelet
70-
self.ssl_settings = self._init_ssl_settings(instance)
71-
self.kubelet_api_url = self._locate_kubelet(instance)
72-
if not self.kubelet_api_url:
73-
log.exception("Kubernetes check exiting, cannot run without access to kubelet.")
74-
return
71+
self.tls_settings = self._init_tls_settings(instance)
72+
try:
73+
self.kubelet_api_url = self._locate_kubelet(instance)
74+
if not self.kubelet_api_url:
75+
raise Exception("Couldn't find a method to connect to kubelet.")
76+
except Exception as ex:
77+
log.error("Kubernetes check exiting, cannot run without access to kubelet.")
78+
raise ex
79+
7580
self.kubelet_host = self.kubelet_api_url.split(':')[1].lstrip('/')
7681
self.pods_list_url = urljoin(self.kubelet_api_url, KubeUtil.PODS_LIST_PATH)
7782
self.kube_health_url = urljoin(self.kubelet_api_url, KubeUtil.KUBELET_HEALTH_PATH)
@@ -86,24 +91,24 @@ def __init__(self, instance=None):
8691
# default value is 0 but TTL for k8s events is one hour anyways
8792
self.last_event_collection_ts = 0
8893

89-
def _init_ssl_settings(self, instance):
94+
def _init_tls_settings(self, instance):
9095
"""
91-
Extract SSL settings from the config.
96+
Extract TLS settings from the config.
9297
"""
93-
ssl_settings = {}
98+
tls_settings = {}
9499

95100
client_crt = instance.get('kubelet_client_crt')
96101
client_key = instance.get('kubelet_client_key')
97-
if client_crt and client_key:
98-
ssl_settings['kubelet_client_cert'] = (client_crt, client_key)
102+
if client_crt and client_key and os.path.exists(client_crt) and os.path.exists(client_key):
103+
tls_settings['kubelet_client_cert'] = (client_crt, client_key)
99104

100105
cert = instance.get('kubelet_cert')
101106
if cert:
102-
ssl_settings['verify'] = cert
107+
tls_settings['verify'] = cert
103108
else:
104-
ssl_settings['verify'] = instance.get('kubelet_ssl_verify', DEFAULT_SSL_VERIFY)
109+
tls_settings['verify'] = instance.get('kubelet_tls_verify', DEFAULT_TLS_VERIFY)
105110

106-
return ssl_settings
111+
return tls_settings
107112

108113
def _locate_kubelet(self, instance):
109114
"""
@@ -113,10 +118,10 @@ def _locate_kubelet(self, instance):
113118
"""
114119
host = os.environ.get('KUBERNETES_KUBELET_HOST') or instance.get("host")
115120
if not host:
116-
# if no hostname was provided, use the docker hostname if ssl
121+
# if no hostname was provided, use the docker hostname if cert
117122
# validation is not required, the kubernetes hostname otherwise.
118123
docker_hostname = self.docker_util.get_hostname()
119-
if self.ssl_settings['verify']:
124+
if self.tls_settings['verify']:
120125
try:
121126
k8s_hostname = self.get_node_hostname(docker_hostname)
122127
host = k8s_hostname or docker_hostname
@@ -130,30 +135,31 @@ def _locate_kubelet(self, instance):
130135
port = instance.get('kubelet_port', KubeUtil.DEFAULT_HTTP_KUBELET_PORT)
131136
no_auth_url = 'http://%s:%s' % (host, port)
132137
test_url = urljoin(no_auth_url, KubeUtil.KUBELET_HEALTH_PATH)
133-
self.retrieve_kubelet_url(test_url)
138+
self.perform_kubelet_query(test_url)
134139
return no_auth_url
135140
except Exception:
136141
log.debug("Couldn't query kubelet over HTTP, assuming it's not in no_auth mode.")
137142

138-
try:
139-
port = instance.get('kubelet_port', KubeUtil.DEFAULT_HTTPS_KUBELET_PORT)
143+
port = instance.get('kubelet_port', KubeUtil.DEFAULT_HTTPS_KUBELET_PORT)
140144

141-
https_url = 'https://%s:%s' % (host, port)
142-
test_url = urljoin(https_url, KubeUtil.KUBELET_HEALTH_PATH)
143-
self.retrieve_kubelet_url(test_url)
145+
https_url = 'https://%s:%s' % (host, port)
146+
test_url = urljoin(https_url, KubeUtil.KUBELET_HEALTH_PATH)
147+
self.perform_kubelet_query(test_url)
144148

145-
return https_url
146-
except Exception as ex:
147-
log.error("Kubelet unreachable.\nPlease configure the no-auth endpoint or configure authentication: %s" % str(ex))
149+
return https_url
148150

149151
def get_node_hostname(self, host):
150-
node_filter = 'labelSelector=kubernetes.io/hostname'
152+
"""
153+
Query the API server for the kubernetes hostname of the node
154+
using the docker hostname as a filter.
155+
"""
156+
node_filter = {'labelSelector': 'kubernetes.io/hostname=%s' % host}
151157
node = self.retrieve_json_auth(
152-
self.kubernetes_api_url + '/nodes?%s%%3D%s' % (node_filter, host),
158+
self.kubernetes_api_url + '/nodes?%s' % urlencode(node_filter),
153159
self.get_auth_token()
154160
)
155161
if len(node['items']) != 1:
156-
raise Exception('Error while getting node hostname: expected 1 node, got %s.' % len(node['items']))
162+
log.error('Error while getting node hostname: expected 1 node, got %s.' % len(node['items']))
157163
else:
158164
addresses = (node or {}).get('items', [{}])[0].get('status', {}).get('addresses', [])
159165
for address in addresses:
@@ -195,7 +201,7 @@ def retrieve_pods_list(self):
195201
196202
TODO: the list of pods could be cached with some policy to be decided.
197203
"""
198-
return self.retrieve_kubelet_url(self.pods_list_url).json()
204+
return self.perform_kubelet_query(self.pods_list_url).json()
199205

200206
def retrieve_machine_info(self):
201207
"""
@@ -209,19 +215,18 @@ def retrieve_metrics(self):
209215
"""
210216
return retrieve_json(self.metrics_url)
211217

212-
def retrieve_kubelet_url(self, url, verbose=True, timeout=10):
218+
def perform_kubelet_query(self, url, verbose=True, timeout=10):
213219
"""
214-
Perform and return a GET request against kubelet. Support auth and SSL validation.
220+
Perform and return a GET request against kubelet. Support auth and TLS validation.
215221
"""
216-
ssl_context = self.ssl_settings
217-
218-
cert = headers = None
219-
verify = ssl_context.get('verify', DEFAULT_SSL_VERIFY)
222+
tls_context = self.tls_settings
220223

221-
if 'kubelet_client_cert' in ssl_context:
222-
cert = ssl_context['kubelet_client_cert']
224+
headers = None
225+
cert = tls_context.get('kubelet_client_cert')
226+
verify = tls_context.get('verify', DEFAULT_TLS_VERIFY)
223227

224-
if url.lower().startswith('https'):
228+
# if cert-based auth is enabled, don't use the token.
229+
if not cert and url.lower().startswith('https'):
225230
headers = {'Authorization': 'Bearer {}'.format(self.get_auth_token())}
226231

227232
return requests.get(url, timeout=timeout, verify=verify,
@@ -232,11 +237,11 @@ def retrieve_json_auth(self, url, auth_token, timeout=10, verify=None):
232237
Kubernetes API requires authentication using a token available in
233238
every pod.
234239
235-
We try to verify ssl certificate if available.
240+
We try to verify the certificate if available.
236241
"""
237242
if verify is None:
238243
verify = self.CA_CRT_PATH if os.path.exists(self.CA_CRT_PATH) else False
239-
log.debug('ssl validation: {}'.format(verify))
244+
log.debug('tls validation: {}'.format(verify))
240245
headers = {'Authorization': 'Bearer {}'.format(auth_token)}
241246
r = requests.get(url, timeout=timeout, headers=headers, verify=verify)
242247
r.raise_for_status()

0 commit comments

Comments
 (0)