openshift_cert_expiry.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835
  1. #!/usr/bin/python
  2. # -*- coding: utf-8 -*-
  3. # pylint: disable=line-too-long,invalid-name
  4. """For details on this module see DOCUMENTATION (below)"""
  5. import base64
  6. import datetime
  7. import io
  8. import os
  9. import subprocess
  10. import yaml
  11. import dateutil.parser
  12. # pylint import-error disabled because pylint cannot find the package
  13. # when installed in a virtualenv
  14. from ansible.module_utils.six.moves import configparser # pylint: disable=import-error
  15. from ansible.module_utils.basic import AnsibleModule
  16. try:
  17. # You can comment this import out and include a 'pass' in this
  18. # block if you're manually testing this module on a NON-ATOMIC
  19. # HOST (or any host that just doesn't have PyOpenSSL
  20. # available). That will force the `load_and_handle_cert` function
  21. # to use the Fake OpenSSL classes.
  22. import OpenSSL.crypto
  23. HAS_OPENSSL = True
  24. except ImportError:
  25. # Some platforms (such as RHEL Atomic) may not have the Python
  26. # OpenSSL library installed. In this case we will use a manual
  27. # work-around to parse each certificate.
  28. #
  29. # Check for 'OpenSSL.crypto' in `sys.modules` later.
  30. HAS_OPENSSL = False
  31. DOCUMENTATION = '''
  32. ---
  33. module: openshift_cert_expiry
  34. short_description: Check OpenShift Container Platform (OCP) and Kube certificate expirations on a cluster
  35. description:
  36. - The M(openshift_cert_expiry) module has two basic functions: to flag certificates which will expire in a set window of time from now, and to notify you about certificates which have already expired.
  37. - When the module finishes, a summary of the examination is returned. Each certificate in the summary has a C(health) key with a value of one of the following:
  38. - C(ok) - not expired, and outside of the expiration C(warning_days) window.
  39. - C(warning) - not expired, but will expire between now and the C(warning_days) window.
  40. - C(expired) - an expired certificate.
  41. - Certificate flagging follow this logic:
  42. - If the expiration date is before now then the certificate is classified as C(expired).
  43. - The certificates time to live (expiration date - now) is calculated, if that time window is less than C(warning_days) the certificate is classified as C(warning).
  44. - All other conditions are classified as C(ok).
  45. - The following keys are ALSO present in the certificate summary:
  46. - C(cert_cn) - The common name of the certificate (additional CNs present in SAN extensions are omitted)
  47. - C(days_remaining) - The number of days until the certificate expires.
  48. - C(expiry) - The date the certificate expires on.
  49. - C(path) - The full path to the certificate on the examined host.
  50. version_added: "1.0"
  51. options:
  52. config_base:
  53. description:
  54. - Base path to OCP system settings.
  55. required: false
  56. default: /etc/origin
  57. warning_days:
  58. description:
  59. - Flag certificates which will expire in C(warning_days) days from now.
  60. required: false
  61. default: 30
  62. show_all:
  63. description:
  64. - Enable this option to show analysis of ALL certificates examined by this module.
  65. - By default only certificates which have expired, or will expire within the C(warning_days) window will be reported.
  66. required: false
  67. default: false
  68. author: "Tim Bielawa (@tbielawa) <tbielawa@redhat.com>"
  69. '''
  70. EXAMPLES = '''
  71. # Default invocation, only notify about expired certificates or certificates which will expire within 30 days from now
  72. - openshift_cert_expiry:
  73. # Expand the warning window to show certificates expiring within a year from now
  74. - openshift_cert_expiry: warning_days=365
  75. # Show expired, soon to expire (now + 30 days), and all other certificates examined
  76. - openshift_cert_expiry: show_all=true
  77. '''
  78. class FakeOpenSSLCertificate(object):
  79. """This provides a rough mock of what you get from
  80. `OpenSSL.crypto.load_certificate()`. This is a work-around for
  81. platforms missing the Python OpenSSL library.
  82. """
  83. def __init__(self, cert_string):
  84. """`cert_string` is a certificate in the form you get from running a
  85. .crt through 'openssl x509 -in CERT.cert -text'"""
  86. self.cert_string = cert_string
  87. self.serial = None
  88. self.subject = None
  89. self.extensions = []
  90. self.not_after = None
  91. self._parse_cert()
  92. def _parse_cert(self):
  93. """Manually parse the certificate line by line"""
  94. self.extensions = []
  95. PARSING_ALT_NAMES = False
  96. PARSING_HEX_SERIAL = False
  97. for line in self.cert_string.split('\n'):
  98. l = line.strip()
  99. if PARSING_ALT_NAMES:
  100. # We're parsing a 'Subject Alternative Name' line
  101. self.extensions.append(
  102. FakeOpenSSLCertificateSANExtension(l))
  103. PARSING_ALT_NAMES = False
  104. continue
  105. if PARSING_HEX_SERIAL:
  106. # Hex serials arrive colon-delimited
  107. serial_raw = l.replace(':', '')
  108. # Convert to decimal
  109. self.serial = int('0x' + serial_raw, base=16)
  110. PARSING_HEX_SERIAL = False
  111. continue
  112. # parse out the bits that we can
  113. if l.startswith('Serial Number:'):
  114. # Decimal format:
  115. # Serial Number: 11 (0xb)
  116. # => 11
  117. # Hex Format (large serials):
  118. # Serial Number:
  119. # 0a:de:eb:24:04:75:ab:56:39:14:e9:5a:22:e2:85:bf
  120. # => 14449739080294792594019643629255165375
  121. if l.endswith(':'):
  122. PARSING_HEX_SERIAL = True
  123. continue
  124. self.serial = int(l.split()[-2])
  125. elif l.startswith('Not After :'):
  126. # Not After : Feb 7 18:19:35 2019 GMT
  127. # => strptime(str, '%b %d %H:%M:%S %Y %Z')
  128. # => strftime('%Y%m%d%H%M%SZ')
  129. # => 20190207181935Z
  130. not_after_raw = l.partition(' : ')[-1]
  131. # Last item: ('Not After', ' : ', 'Feb 7 18:19:35 2019 GMT')
  132. not_after_parsed = dateutil.parser.parse(not_after_raw)
  133. self.not_after = not_after_parsed.strftime('%Y%m%d%H%M%SZ')
  134. elif l.startswith('X509v3 Subject Alternative Name:'):
  135. PARSING_ALT_NAMES = True
  136. continue
  137. elif l.startswith('Subject:'):
  138. # O = system:nodes, CN = system:node:m01.example.com
  139. self.subject = FakeOpenSSLCertificateSubjects(l.partition(': ')[-1])
  140. def get_serial_number(self):
  141. """Return the serial number of the cert"""
  142. return self.serial
  143. def get_subject(self):
  144. """Subjects must implement get_components() and return dicts or
  145. tuples. An 'openssl x509 -in CERT.cert -text' with 'Subject':
  146. Subject: Subject: O=system:nodes, CN=system:node:m01.example.com
  147. might return: [('O=system', 'nodes'), ('CN=system', 'node:m01.example.com')]
  148. """
  149. return self.subject
  150. def get_extension(self, i):
  151. """Extensions must implement get_short_name() and return the string
  152. 'subjectAltName'"""
  153. return self.extensions[i]
  154. def get_extension_count(self):
  155. """ get_extension_count """
  156. return len(self.extensions)
  157. def get_notAfter(self):
  158. """Returns a date stamp as a string in the form
  159. '20180922170439Z'. strptime the result with format param:
  160. '%Y%m%d%H%M%SZ'."""
  161. return self.not_after
  162. class FakeOpenSSLCertificateSANExtension(object): # pylint: disable=too-few-public-methods
  163. """Mocks what happens when `get_extension` is called on a certificate
  164. object"""
  165. def __init__(self, san_string):
  166. """With `san_string` as you get from:
  167. $ openssl x509 -in certificate.crt -text
  168. """
  169. self.san_string = san_string
  170. self.short_name = 'subjectAltName'
  171. def get_short_name(self):
  172. """Return the 'type' of this extension. It's always the same though
  173. because we only care about subjectAltName's"""
  174. return self.short_name
  175. def __str__(self):
  176. """Return this extension and the value as a simple string"""
  177. return self.san_string
  178. # pylint: disable=too-few-public-methods
  179. class FakeOpenSSLCertificateSubjects(object):
  180. """Mocks what happens when `get_subject` is called on a certificate
  181. object"""
  182. def __init__(self, subject_string):
  183. """With `subject_string` as you get from:
  184. $ openssl x509 -in certificate.crt -text
  185. """
  186. self.subjects = []
  187. for s in subject_string.split(', '):
  188. name, _, value = s.partition(' = ')
  189. self.subjects.append((name, value))
  190. def get_components(self):
  191. """Returns a list of tuples"""
  192. return self.subjects
  193. ######################################################################
  194. def filter_paths(path_list):
  195. """`path_list` - A list of file paths to check. Only files which exist
  196. will be returned
  197. """
  198. return [p for p in path_list if os.path.exists(os.path.realpath(p))]
  199. # pylint: disable=too-many-locals,too-many-branches
  200. #
  201. # TODO: Break this function down into smaller chunks
  202. def load_and_handle_cert(cert_string, now, base64decode=False, ans_module=None):
  203. """Load a certificate, split off the good parts, and return some
  204. useful data
  205. Params:
  206. - `cert_string` (string) - a certificate loaded into a string object
  207. - `now` (datetime) - a datetime object of the time to calculate the certificate 'time_remaining' against
  208. - `base64decode` (bool) - run base64.b64decode() on the input
  209. - `ans_module` (AnsibleModule) - The AnsibleModule object for this module (so we can raise errors)
  210. Returns:
  211. A tuple of the form:
  212. (cert_subject, cert_expiry_date, time_remaining, cert_serial_number)
  213. """
  214. if base64decode:
  215. _cert_string = base64.b64decode(cert_string).decode('utf-8')
  216. else:
  217. _cert_string = cert_string
  218. # Disable this. We 'redefine' the type because we are working
  219. # around a missing library on the target host.
  220. #
  221. # pylint: disable=redefined-variable-type
  222. if HAS_OPENSSL:
  223. # No work-around required
  224. cert_loaded = OpenSSL.crypto.load_certificate(
  225. OpenSSL.crypto.FILETYPE_PEM, _cert_string)
  226. else:
  227. # Missing library, work-around required. Run the 'openssl'
  228. # command on it to decode it
  229. cmd = 'openssl x509 -text'
  230. try:
  231. openssl_proc = subprocess.Popen(cmd.split(),
  232. stdout=subprocess.PIPE,
  233. stdin=subprocess.PIPE)
  234. except OSError:
  235. ans_module.fail_json(msg="Error: The 'OpenSSL' python library and CLI command were not found on the target host. Unable to parse any certificates. This host will not be included in generated reports.")
  236. else:
  237. openssl_decoded = openssl_proc.communicate(_cert_string.encode('utf-8'))[0].decode('utf-8')
  238. cert_loaded = FakeOpenSSLCertificate(openssl_decoded)
  239. ######################################################################
  240. # Read all possible names from the cert
  241. cert_subjects = []
  242. for name, value in cert_loaded.get_subject().get_components():
  243. if isinstance(name, bytes) or isinstance(value, bytes):
  244. name = name.decode('utf-8')
  245. value = value.decode('utf-8')
  246. cert_subjects.append('{}:{}'.format(name, value))
  247. # To read SANs from a cert we must read the subjectAltName
  248. # extension from the X509 Object. What makes this more difficult
  249. # is that pyOpenSSL does not give extensions as an iterable
  250. san = None
  251. for i in range(cert_loaded.get_extension_count()):
  252. ext = cert_loaded.get_extension(i)
  253. if ext.get_short_name() == 'subjectAltName':
  254. san = ext
  255. if san is not None:
  256. # The X509Extension object for subjectAltName prints as a
  257. # string with the alt names separated by a comma and a
  258. # space. Split the string by ', ' and then add our new names
  259. # to the list of existing names
  260. cert_subjects.extend(str(san).split(', '))
  261. cert_subject = ', '.join(cert_subjects)
  262. ######################################################################
  263. # Grab the expiration date
  264. not_after = cert_loaded.get_notAfter()
  265. # example get_notAfter() => 20180922170439Z
  266. if isinstance(not_after, bytes):
  267. not_after = not_after.decode('utf-8')
  268. cert_expiry_date = datetime.datetime.strptime(
  269. not_after,
  270. '%Y%m%d%H%M%SZ')
  271. time_remaining = cert_expiry_date - now
  272. return (cert_subject, cert_expiry_date, time_remaining, cert_loaded.get_serial_number())
  273. def classify_cert(cert_meta, now, time_remaining, expire_window, cert_list):
  274. """Given metadata about a certificate under examination, classify it
  275. into one of three categories, 'ok', 'warning', and 'expired'.
  276. Params:
  277. - `cert_meta` dict - A dict with certificate metadata. Required fields
  278. include: 'cert_cn', 'path', 'expiry', 'days_remaining', 'health'.
  279. - `now` (datetime) - a datetime object of the time to calculate the certificate 'time_remaining' against
  280. - `time_remaining` (datetime.timedelta) - a timedelta for how long until the cert expires
  281. - `expire_window` (datetime.timedelta) - a timedelta for how long the warning window is
  282. - `cert_list` list - A list to shove the classified cert into
  283. Return:
  284. - `cert_list` - The updated list of classified certificates
  285. """
  286. expiry_str = str(cert_meta['expiry'])
  287. # Categorization
  288. if cert_meta['expiry'] < now:
  289. # This already expired, must NOTIFY
  290. cert_meta['health'] = 'expired'
  291. elif time_remaining < expire_window:
  292. # WARN about this upcoming expirations
  293. cert_meta['health'] = 'warning'
  294. else:
  295. # Not expired or about to expire
  296. cert_meta['health'] = 'ok'
  297. cert_meta['expiry'] = expiry_str
  298. cert_meta['serial_hex'] = hex(int(cert_meta['serial']))
  299. cert_list.append(cert_meta)
  300. return cert_list
  301. def tabulate_summary(certificates, kubeconfigs, etcd_certs, router_certs, registry_certs):
  302. """Calculate the summary text for when the module finishes
  303. running. This includes counts of each classification and what have
  304. you.
  305. Params:
  306. - `certificates` (list of dicts) - Processed `expire_check_result`
  307. dicts with filled in `health` keys for system certificates.
  308. - `kubeconfigs` - as above for kubeconfigs
  309. - `etcd_certs` - as above for etcd certs
  310. Return:
  311. - `summary_results` (dict) - Counts of each cert type classification
  312. and total items examined.
  313. """
  314. items = certificates + kubeconfigs + etcd_certs + router_certs + registry_certs
  315. summary_results = {
  316. 'system_certificates': len(certificates),
  317. 'kubeconfig_certificates': len(kubeconfigs),
  318. 'etcd_certificates': len(etcd_certs),
  319. 'router_certs': len(router_certs),
  320. 'registry_certs': len(registry_certs),
  321. 'total': len(items),
  322. 'ok': 0,
  323. 'warning': 0,
  324. 'expired': 0
  325. }
  326. summary_results['expired'] = len([c for c in items if c['health'] == 'expired'])
  327. summary_results['warning'] = len([c for c in items if c['health'] == 'warning'])
  328. summary_results['ok'] = len([c for c in items if c['health'] == 'ok'])
  329. return summary_results
  330. ######################################################################
  331. # This is our module MAIN function after all, so there's bound to be a
  332. # lot of code bundled up into one block
  333. #
  334. # Reason: These checks are disabled because the issue was introduced
  335. # during a period where the pylint checks weren't enabled for this file
  336. # Status: temporarily disabled pending future refactoring
  337. # pylint: disable=too-many-locals,too-many-statements,too-many-branches
  338. def main():
  339. """This module examines certificates (in various forms) which compose
  340. an OpenShift Container Platform cluster
  341. """
  342. module = AnsibleModule(
  343. argument_spec=dict(
  344. config_base=dict(
  345. required=False,
  346. default="/etc/origin",
  347. type='str'),
  348. warning_days=dict(
  349. required=False,
  350. default=30,
  351. type='int'),
  352. show_all=dict(
  353. required=False,
  354. default=False,
  355. type='bool')
  356. ),
  357. supports_check_mode=True,
  358. )
  359. # Basic scaffolding for OpenShift specific certs
  360. openshift_base_config_path = os.path.realpath(module.params['config_base'])
  361. openshift_master_config_path = os.path.join(openshift_base_config_path,
  362. "master", "master-config.yaml")
  363. openshift_node_config_path = os.path.join(openshift_base_config_path,
  364. "node", "node-config.yaml")
  365. openshift_node_bootstrap_config_path = os.path.join(openshift_base_config_path,
  366. "node", "bootstrap-node-config.yaml")
  367. openshift_cert_check_paths = [
  368. openshift_master_config_path,
  369. openshift_node_config_path,
  370. openshift_node_bootstrap_config_path,
  371. ]
  372. # Paths for Kubeconfigs. Additional kubeconfigs are conditionally
  373. # checked later in the code
  374. master_kube_configs = ['admin', 'openshift-master',
  375. 'openshift-node', 'openshift-router',
  376. 'openshift-registry']
  377. kubeconfig_paths = []
  378. for m_kube_config in master_kube_configs:
  379. kubeconfig_paths.append(
  380. os.path.join(openshift_base_config_path, "master", m_kube_config + ".kubeconfig")
  381. )
  382. # Validate some paths we have the ability to do ahead of time
  383. openshift_cert_check_paths = filter_paths(openshift_cert_check_paths)
  384. kubeconfig_paths = filter_paths(kubeconfig_paths)
  385. # etcd, where do you hide your certs? Used when parsing etcd.conf
  386. etcd_cert_params = [
  387. "ETCD_TRUSTED_CA_FILE",
  388. "ETCD_CERT_FILE",
  389. "ETCD_PEER_TRUSTED_CA_FILE",
  390. "ETCD_PEER_CERT_FILE",
  391. ]
  392. # Expiry checking stuff
  393. now = datetime.datetime.now()
  394. # todo, catch exception for invalid input and return a fail_json
  395. warning_days = int(module.params['warning_days'])
  396. expire_window = datetime.timedelta(days=warning_days)
  397. # Module stuff
  398. #
  399. # The results of our cert checking to return from the task call
  400. check_results = {}
  401. check_results['meta'] = {}
  402. check_results['meta']['warning_days'] = warning_days
  403. check_results['meta']['checked_at_time'] = str(now)
  404. check_results['meta']['warn_before_date'] = str(now + expire_window)
  405. check_results['meta']['show_all'] = str(module.params['show_all'])
  406. # All the analyzed certs accumulate here
  407. ocp_certs = []
  408. ######################################################################
  409. # Sure, why not? Let's enable check mode.
  410. if module.check_mode:
  411. check_results['ocp_certs'] = []
  412. module.exit_json(
  413. check_results=check_results,
  414. msg="Checked 0 total certificates. Expired/Warning/OK: 0/0/0. Warning window: %s days" % module.params['warning_days'],
  415. rc=0,
  416. changed=False
  417. )
  418. ######################################################################
  419. # Check for OpenShift Container Platform specific certs
  420. ######################################################################
  421. for os_cert in filter_paths(openshift_cert_check_paths):
  422. # Open up that config file and locate the cert and CA
  423. with io.open(os_cert, 'r', encoding='utf-8') as fp:
  424. cert_meta = {}
  425. cfg = yaml.load(fp)
  426. # cert files are specified in parsed `fp` as relative to the path
  427. # of the original config file. 'master-config.yaml' with certFile
  428. # = 'foo.crt' implies that 'foo.crt' is in the same
  429. # directory. certFile = '../foo.crt' is in the parent directory.
  430. cfg_path = os.path.dirname(fp.name)
  431. servingInfoFile = cfg.get('servingInfo', {}).get('certFile')
  432. if servingInfoFile:
  433. cert_meta['certFile'] = os.path.join(cfg_path, servingInfoFile)
  434. servingInfoCA = cfg.get('servingInfo', {}).get('clientCA')
  435. if servingInfoCA:
  436. cert_meta['clientCA'] = os.path.join(cfg_path, servingInfoCA)
  437. serviceSigner = cfg.get('controllerConfig', {}).get('serviceServingCert', {}).get('signer', {}).get('certFile')
  438. if serviceSigner:
  439. cert_meta['serviceSigner'] = os.path.join(cfg_path, serviceSigner)
  440. etcdClientCA = cfg.get('etcdClientInfo', {}).get('ca')
  441. if etcdClientCA:
  442. cert_meta['etcdClientCA'] = os.path.join(cfg_path, etcdClientCA)
  443. etcdClientCert = cfg.get('etcdClientInfo', {}).get('certFile')
  444. if etcdClientCert:
  445. cert_meta['etcdClientCert'] = os.path.join(cfg_path, etcdClientCert)
  446. kubeletCert = cfg.get('kubeletClientInfo', {}).get('certFile')
  447. if kubeletCert:
  448. cert_meta['kubeletCert'] = os.path.join(cfg_path, kubeletCert)
  449. proxyClient = cfg.get('kubernetesMasterConfig', {}).get('proxyClientInfo', {}).get('certFile')
  450. if proxyClient:
  451. cert_meta['proxyClient'] = os.path.join(cfg_path, proxyClient)
  452. ######################################################################
  453. # Load the certificate and the CA, parse their expiration dates into
  454. # datetime objects so we can manipulate them later
  455. for v in cert_meta.values():
  456. with io.open(v, 'r', encoding='utf-8') as fp:
  457. cert = fp.read()
  458. (cert_subject,
  459. cert_expiry_date,
  460. time_remaining,
  461. cert_serial) = load_and_handle_cert(cert, now, ans_module=module)
  462. expire_check_result = {
  463. 'cert_cn': cert_subject,
  464. 'path': fp.name,
  465. 'expiry': cert_expiry_date,
  466. 'days_remaining': time_remaining.days,
  467. 'health': None,
  468. 'serial': cert_serial
  469. }
  470. classify_cert(expire_check_result, now, time_remaining, expire_window, ocp_certs)
  471. ######################################################################
  472. # /Check for OpenShift Container Platform specific certs
  473. ######################################################################
  474. ######################################################################
  475. # Check service Kubeconfigs
  476. ######################################################################
  477. kubeconfigs = []
  478. # There may be additional kubeconfigs to check, but their naming
  479. # is less predictable than the ones we've already assembled.
  480. for node_config in [openshift_node_config_path, openshift_node_bootstrap_config_path]:
  481. try:
  482. # Try to read the standard 'node-config.yaml' file to check if
  483. # this host is a node.
  484. with io.open(node_config, 'r', encoding='utf-8') as fp:
  485. cfg = yaml.load(fp)
  486. # OK, the config file exists, therefore this is a
  487. # node. Nodes have their own kubeconfig files to
  488. # communicate with the master API. Let's read the relative
  489. # path to that file from the node config.
  490. node_masterKubeConfig = cfg['masterKubeConfig']
  491. # As before, the path to the 'masterKubeConfig' file is
  492. # relative to `fp`
  493. cfg_path = os.path.dirname(fp.name)
  494. node_kubeconfig = os.path.join(cfg_path, node_masterKubeConfig)
  495. with io.open(node_kubeconfig, 'r', encoding='utf8') as fp:
  496. # Read in the nodes kubeconfig file and grab the good stuff
  497. cfg = yaml.load(fp)
  498. c = cfg['users'][0]['user'].get('client-certificate-data')
  499. if not c:
  500. # This is not a node
  501. raise IOError
  502. (cert_subject,
  503. cert_expiry_date,
  504. time_remaining,
  505. cert_serial) = load_and_handle_cert(c, now, base64decode=True, ans_module=module)
  506. expire_check_result = {
  507. 'cert_cn': cert_subject,
  508. 'path': fp.name,
  509. 'expiry': cert_expiry_date,
  510. 'days_remaining': time_remaining.days,
  511. 'health': None,
  512. 'serial': cert_serial
  513. }
  514. classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs)
  515. except IOError:
  516. # This is not a node
  517. pass
  518. for kube in filter_paths(kubeconfig_paths):
  519. with io.open(kube, 'r', encoding='utf-8') as fp:
  520. # TODO: Maybe consider catching exceptions here?
  521. cfg = yaml.load(fp)
  522. # Per conversation, "the kubeconfigs you care about:
  523. # admin, router, registry should all be single
  524. # value". Following that advice we only grab the data for
  525. # the user at index 0 in the 'users' list. There should
  526. # not be more than one user.
  527. c = cfg['users'][0]['user']['client-certificate-data']
  528. (cert_subject,
  529. cert_expiry_date,
  530. time_remaining,
  531. cert_serial) = load_and_handle_cert(c, now, base64decode=True, ans_module=module)
  532. expire_check_result = {
  533. 'cert_cn': cert_subject,
  534. 'path': fp.name,
  535. 'expiry': cert_expiry_date,
  536. 'days_remaining': time_remaining.days,
  537. 'health': None,
  538. 'serial': cert_serial
  539. }
  540. classify_cert(expire_check_result, now, time_remaining, expire_window, kubeconfigs)
  541. ######################################################################
  542. # /Check service Kubeconfigs
  543. ######################################################################
  544. ######################################################################
  545. # Check etcd certs
  546. #
  547. # Two things to check: 'external' etcd, and embedded etcd.
  548. ######################################################################
  549. # FIRST: The 'external' etcd
  550. #
  551. # Some values may be duplicated, make this a set for now so we
  552. # unique them all
  553. etcd_certs_to_check = set([])
  554. etcd_certs = []
  555. etcd_cert_params.append('dne')
  556. try:
  557. with io.open('/etc/etcd/etcd.conf', 'r', encoding='utf-8') as fp:
  558. # Add dummy header section.
  559. config = io.StringIO()
  560. config.write(u'[ETCD]\n')
  561. config.write(fp.read().replace('%', '%%'))
  562. config.seek(0, os.SEEK_SET)
  563. etcd_config = configparser.ConfigParser()
  564. etcd_config.readfp(config)
  565. for param in etcd_cert_params:
  566. try:
  567. etcd_certs_to_check.add(etcd_config.get('ETCD', param))
  568. except configparser.NoOptionError:
  569. # That parameter does not exist, oh well...
  570. pass
  571. except IOError:
  572. # No etcd to see here, move along
  573. pass
  574. for etcd_cert in filter_paths(etcd_certs_to_check):
  575. with io.open(etcd_cert, 'r', encoding='utf-8') as fp:
  576. c = fp.read()
  577. (cert_subject,
  578. cert_expiry_date,
  579. time_remaining,
  580. cert_serial) = load_and_handle_cert(c, now, ans_module=module)
  581. expire_check_result = {
  582. 'cert_cn': cert_subject,
  583. 'path': fp.name,
  584. 'expiry': cert_expiry_date,
  585. 'days_remaining': time_remaining.days,
  586. 'health': None,
  587. 'serial': cert_serial
  588. }
  589. classify_cert(expire_check_result, now, time_remaining, expire_window, etcd_certs)
  590. ######################################################################
  591. # /Check etcd certs
  592. ######################################################################
  593. ######################################################################
  594. # Check router/registry certs
  595. #
  596. # These are saved as secrets in etcd. That means that we can not
  597. # simply read a file to grab the data. Instead we're going to
  598. # subprocess out to the 'oc get' command. On non-masters this
  599. # command will fail, that is expected so we catch that exception.
  600. ######################################################################
  601. router_certs = []
  602. registry_certs = []
  603. ######################################################################
  604. # First the router certs
  605. try:
  606. router_secrets_raw = subprocess.Popen('oc get -n default secret router-certs -o yaml'.split(),
  607. stdout=subprocess.PIPE)
  608. router_ds = yaml.load(router_secrets_raw.communicate()[0])
  609. router_c = router_ds['data']['tls.crt']
  610. router_path = router_ds['metadata']['selfLink']
  611. except TypeError:
  612. # YAML couldn't load the result, this is not a master
  613. pass
  614. except OSError:
  615. # The OC command doesn't exist here. Move along.
  616. pass
  617. else:
  618. (cert_subject,
  619. cert_expiry_date,
  620. time_remaining,
  621. cert_serial) = load_and_handle_cert(router_c, now, base64decode=True, ans_module=module)
  622. expire_check_result = {
  623. 'cert_cn': cert_subject,
  624. 'path': router_path,
  625. 'expiry': cert_expiry_date,
  626. 'days_remaining': time_remaining.days,
  627. 'health': None,
  628. 'serial': cert_serial
  629. }
  630. classify_cert(expire_check_result, now, time_remaining, expire_window, router_certs)
  631. ######################################################################
  632. # Now for registry
  633. try:
  634. registry_secrets_raw = subprocess.Popen('oc get -n default secret registry-certificates -o yaml'.split(),
  635. stdout=subprocess.PIPE)
  636. registry_ds = yaml.load(registry_secrets_raw.communicate()[0])
  637. registry_c = registry_ds['data']['registry.crt']
  638. registry_path = registry_ds['metadata']['selfLink']
  639. except TypeError:
  640. # YAML couldn't load the result, this is not a master
  641. pass
  642. except OSError:
  643. # The OC command doesn't exist here. Move along.
  644. pass
  645. else:
  646. (cert_subject,
  647. cert_expiry_date,
  648. time_remaining,
  649. cert_serial) = load_and_handle_cert(registry_c, now, base64decode=True, ans_module=module)
  650. expire_check_result = {
  651. 'cert_cn': cert_subject,
  652. 'path': registry_path,
  653. 'expiry': cert_expiry_date,
  654. 'days_remaining': time_remaining.days,
  655. 'health': None,
  656. 'serial': cert_serial
  657. }
  658. classify_cert(expire_check_result, now, time_remaining, expire_window, registry_certs)
  659. ######################################################################
  660. # /Check router/registry certs
  661. ######################################################################
  662. res = tabulate_summary(ocp_certs, kubeconfigs, etcd_certs, router_certs, registry_certs)
  663. warn_certs = bool(res['expired'] + res['warning'])
  664. msg = "Checked {count} total certificates. Expired/Warning/OK: {exp}/{warn}/{ok}. Warning window: {window} days".format(
  665. count=res['total'],
  666. exp=res['expired'],
  667. warn=res['warning'],
  668. ok=res['ok'],
  669. window=int(module.params['warning_days']),
  670. )
  671. # By default we only return detailed information about expired or
  672. # warning certificates. If show_all is true then we will print all
  673. # the certificates examined.
  674. if not module.params['show_all']:
  675. check_results['ocp_certs'] = [crt for crt in ocp_certs if crt['health'] in ['expired', 'warning']]
  676. check_results['kubeconfigs'] = [crt for crt in kubeconfigs if crt['health'] in ['expired', 'warning']]
  677. check_results['etcd'] = [crt for crt in etcd_certs if crt['health'] in ['expired', 'warning']]
  678. check_results['registry'] = [crt for crt in registry_certs if crt['health'] in ['expired', 'warning']]
  679. check_results['router'] = [crt for crt in router_certs if crt['health'] in ['expired', 'warning']]
  680. else:
  681. check_results['ocp_certs'] = ocp_certs
  682. check_results['kubeconfigs'] = kubeconfigs
  683. check_results['etcd'] = etcd_certs
  684. check_results['registry'] = registry_certs
  685. check_results['router'] = router_certs
  686. # Sort the final results to report in order of ascending safety
  687. # time. That is to say, the certificates which will expire sooner
  688. # will be at the front of the list and certificates which will
  689. # expire later are at the end. Router and registry certs should be
  690. # limited to just 1 result, so don't bother sorting those.
  691. def cert_key(item):
  692. ''' return the days_remaining key '''
  693. return item['days_remaining']
  694. check_results['ocp_certs'] = sorted(check_results['ocp_certs'], key=cert_key)
  695. check_results['kubeconfigs'] = sorted(check_results['kubeconfigs'], key=cert_key)
  696. check_results['etcd'] = sorted(check_results['etcd'], key=cert_key)
  697. # This module will never change anything, but we might want to
  698. # change the return code parameter if there is some catastrophic
  699. # error we noticed earlier
  700. module.exit_json(
  701. check_results=check_results,
  702. warn_certs=warn_certs,
  703. summary=res,
  704. msg=msg,
  705. rc=0,
  706. changed=False
  707. )
  708. if __name__ == '__main__':
  709. main()