Certificate rotation enhancements (#570)
This commit is contained in:
29
lemur/notifications/cli.py
Normal file
29
lemur/notifications/cli.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""
|
||||
.. module: lemur.notifications.cli
|
||||
:platform: Unix
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask_script import Manager
|
||||
|
||||
from lemur.notifications.messaging import send_expiration_notifications
|
||||
|
||||
manager = Manager(usage="Handles notification related tasks.")
|
||||
|
||||
|
||||
@manager.command
|
||||
def notify():
|
||||
"""
|
||||
Runs Lemur's notification engine, that looks for expired certificates and sends
|
||||
notifications out to those that have subscribed to them.
|
||||
|
||||
:return:
|
||||
"""
|
||||
print("Starting to notify subscribers about expiring certificates!")
|
||||
count = send_expiration_notifications()
|
||||
print(
|
||||
"Finished notifying subscribers about expiring certificates! Sent {count} notifications!".format(
|
||||
count=count
|
||||
)
|
||||
)
|
102
lemur/notifications/messaging.py
Normal file
102
lemur/notifications/messaging.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""
|
||||
.. module: lemur.notifications.messaging
|
||||
:platform: Unix
|
||||
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
"""
|
||||
|
||||
import arrow
|
||||
from flask import current_app
|
||||
|
||||
from lemur import database, metrics
|
||||
from lemur.certificates.schemas import certificate_notification_output_schema
|
||||
from lemur.notifications.models import Notification
|
||||
from lemur.plugins import plugins
|
||||
from lemur.plugins.utils import get_plugin_option
|
||||
|
||||
|
||||
def send_expiration_notifications():
|
||||
"""
|
||||
This function will check for upcoming certificate expiration,
|
||||
and send out notification emails at given intervals.
|
||||
"""
|
||||
for plugin in plugins.all(plugin_type='notification'):
|
||||
notifications = database.db.session.query(Notification)\
|
||||
.filter(Notification.plugin_name == plugin.slug)\
|
||||
.filter(Notification.active == True).all() # noqa
|
||||
|
||||
messages = []
|
||||
for n in notifications:
|
||||
for certificate in n.certificates:
|
||||
if needs_notification(certificate):
|
||||
data = certificate_notification_output_schema.dump(certificate).data
|
||||
messages.append((data, n.options))
|
||||
|
||||
for data, targets, options in messages:
|
||||
try:
|
||||
plugin.send('expiration', data, targets, options)
|
||||
metrics.send('expiration_notification_sent', 'counter', 1)
|
||||
except Exception as e:
|
||||
metrics.send('expiration_notification_failure', 'counter', 1)
|
||||
current_app.logger.exception(e)
|
||||
|
||||
|
||||
def send_rotation_notification(certificate):
|
||||
"""
|
||||
Sends a report to certificate owners when their certificate as been
|
||||
rotated.
|
||||
|
||||
:param certificate:
|
||||
:return:
|
||||
"""
|
||||
plugin = plugins.get(current_app.config.get('LEMUR_DEFAULT_NOTIFICATION_PLUGIN'))
|
||||
|
||||
data = certificate_notification_output_schema.dump(certificate).data
|
||||
|
||||
try:
|
||||
plugin.send('rotation', data, [data.owner])
|
||||
metrics.send('rotation_notification_sent', 'counter', 1)
|
||||
except Exception as e:
|
||||
metrics.send('rotation_notification_failure', 'counter', 1)
|
||||
current_app.logger.exception(e)
|
||||
|
||||
|
||||
def needs_notification(certificate):
|
||||
"""
|
||||
Determine if notifications for a given certificate should
|
||||
currently be sent
|
||||
|
||||
:param certificate:
|
||||
:return:
|
||||
"""
|
||||
if not certificate.notify:
|
||||
return
|
||||
|
||||
if not certificate.notifications:
|
||||
return
|
||||
|
||||
now = arrow.utcnow()
|
||||
days = (certificate.not_after - now).days
|
||||
|
||||
for notification in certificate.notifications:
|
||||
interval = get_plugin_option('interval', notification.options)
|
||||
unit = get_plugin_option('unit', notification.options)
|
||||
|
||||
if unit == 'weeks':
|
||||
interval *= 7
|
||||
|
||||
elif unit == 'months':
|
||||
interval *= 30
|
||||
|
||||
elif unit == 'days': # it's nice to be explicit about the base unit
|
||||
pass
|
||||
|
||||
else:
|
||||
raise Exception("Invalid base unit for expiration interval: {0}".format(unit))
|
||||
|
||||
if days == interval:
|
||||
return certificate
|
@ -1,5 +1,5 @@
|
||||
"""
|
||||
.. module: lemur.notifications
|
||||
.. module: lemur.notifications.service
|
||||
:platform: Unix
|
||||
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
@ -8,181 +8,11 @@
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
|
||||
"""
|
||||
import ssl
|
||||
|
||||
import arrow
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from lemur import database
|
||||
from lemur.domains.models import Domain
|
||||
from lemur.notifications.models import Notification
|
||||
from lemur.certificates.models import Certificate
|
||||
|
||||
from lemur.certificates import service as cert_service
|
||||
|
||||
from lemur.plugins.base import plugins
|
||||
|
||||
|
||||
def get_options(name, options):
|
||||
for o in options:
|
||||
if o.get('name') == name:
|
||||
return o
|
||||
|
||||
|
||||
def _get_message_data(cert):
|
||||
"""
|
||||
Parse our the certification information needed for our notification
|
||||
|
||||
:param cert:
|
||||
:return:
|
||||
"""
|
||||
cert_dict = {}
|
||||
|
||||
if cert.user:
|
||||
cert_dict['creator'] = cert.user.email
|
||||
|
||||
cert_dict['not_after'] = cert.not_after
|
||||
cert_dict['owner'] = cert.owner
|
||||
cert_dict['name'] = cert.name
|
||||
cert_dict['body'] = cert.body
|
||||
cert_dict['endpoints'] = [{'name': x.name, 'dnsname': x.dnsname} for x in cert.endpoints]
|
||||
|
||||
return cert_dict
|
||||
|
||||
|
||||
def _deduplicate(messages):
|
||||
"""
|
||||
Take all of the messages that should be sent and provide
|
||||
a roll up to the same set if the recipients are the same
|
||||
"""
|
||||
roll_ups = []
|
||||
for data, options in messages:
|
||||
o = get_options('recipients', options)
|
||||
targets = o['value'].split(',')
|
||||
|
||||
for m, r, o in roll_ups:
|
||||
if r == targets:
|
||||
for cert in m:
|
||||
if cert['body'] == data['body']:
|
||||
break
|
||||
else:
|
||||
m.append(data)
|
||||
current_app.logger.info(
|
||||
"Sending expiration alert about {0} to {1}".format(
|
||||
data['name'], ",".join(targets)))
|
||||
break
|
||||
else:
|
||||
roll_ups.append(([data], targets, options))
|
||||
|
||||
return roll_ups
|
||||
|
||||
|
||||
def send_expiration_notifications():
|
||||
"""
|
||||
This function will check for upcoming certificate expiration,
|
||||
and send out notification emails at given intervals.
|
||||
"""
|
||||
sent = 0
|
||||
|
||||
for plugin in plugins.all(plugin_type='notification'):
|
||||
notifications = database.db.session.query(Notification)\
|
||||
.filter(Notification.plugin_name == plugin.slug)\
|
||||
.filter(Notification.active == True).all() # noqa
|
||||
|
||||
messages = []
|
||||
for n in notifications:
|
||||
for c in n.certificates:
|
||||
if _is_eligible_for_notifications(c):
|
||||
messages.append((_get_message_data(c), n.options))
|
||||
|
||||
messages = _deduplicate(messages)
|
||||
|
||||
for data, targets, options in messages:
|
||||
sent += 1
|
||||
plugin.send('expiration', data, targets, options)
|
||||
|
||||
current_app.logger.info("Lemur has sent {0} certification notifications".format(sent))
|
||||
return sent
|
||||
|
||||
|
||||
def _get_domain_certificate(name):
|
||||
"""
|
||||
Fetch the SSL certificate currently hosted at a given domain (if any) and
|
||||
compare it against our all of our know certificates to determine if a new
|
||||
SSL certificate has already been deployed
|
||||
|
||||
:param name:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
pub_key = ssl.get_server_certificate((name, 443))
|
||||
return cert_service.find_duplicates(pub_key.strip())
|
||||
except Exception as e:
|
||||
current_app.logger.info(str(e))
|
||||
return []
|
||||
|
||||
|
||||
def _find_superseded(cert):
|
||||
"""
|
||||
Here we try to fetch any domain in the certificate to see if we can resolve it
|
||||
and to try and see if it is currently serving the certificate we are
|
||||
alerting on.
|
||||
|
||||
:param domains:
|
||||
:return:
|
||||
"""
|
||||
query = database.session_query(Certificate)
|
||||
ss_list = []
|
||||
|
||||
# determine what is current host at our domains
|
||||
for domain in cert.domains:
|
||||
dups = _get_domain_certificate(domain.name)
|
||||
for c in dups:
|
||||
if c.body != cert.body:
|
||||
ss_list.append(dups)
|
||||
|
||||
current_app.logger.info("Trying to resolve {0}".format(domain.name))
|
||||
|
||||
# look for other certificates that may not be hosted but cover the same domains
|
||||
query = query.filter(Certificate.domains.any(Domain.name.in_([x.name for x in cert.domains])))
|
||||
query = query.filter(Certificate.active == True) # noqa
|
||||
query = query.filter(Certificate.not_after >= arrow.utcnow().format('YYYY-MM-DD'))
|
||||
query = query.filter(Certificate.body != cert.body)
|
||||
ss_list.extend(query.all())
|
||||
return ss_list
|
||||
|
||||
|
||||
def _is_eligible_for_notifications(cert):
|
||||
"""
|
||||
Determine if notifications for a given certificate should
|
||||
currently be sent
|
||||
|
||||
:param cert:
|
||||
:return:
|
||||
"""
|
||||
if not cert.notify:
|
||||
return
|
||||
|
||||
now = arrow.utcnow()
|
||||
days = (cert.not_after - now.naive).days
|
||||
|
||||
for notification in cert.notifications:
|
||||
interval = get_options('interval', notification.options)['value']
|
||||
unit = get_options('unit', notification.options)['value']
|
||||
if unit == 'weeks':
|
||||
interval *= 7
|
||||
|
||||
elif unit == 'months':
|
||||
interval *= 30
|
||||
|
||||
elif unit == 'days': # it's nice to be explicit about the base unit
|
||||
pass
|
||||
|
||||
else:
|
||||
raise Exception("Invalid base unit for expiration interval: {0}".format(unit))
|
||||
|
||||
if days == interval:
|
||||
return cert
|
||||
from lemur.notifications.models import Notification
|
||||
|
||||
|
||||
def create_default_expiration_notifications(name, recipients):
|
||||
|
Reference in New Issue
Block a user