parent
b44a7c73d8
commit
fe9703dd94
@ -79,7 +79,7 @@ gulp.task('dev:styles', function () {
|
||||
'bower_components/angular-loading-bar/src/loading-bar.css',
|
||||
'bower_components/angular-ui-switch/angular-ui-switch.css',
|
||||
'bower_components/angular-wizard/dist/angular-wizard.css',
|
||||
'bower_components/ng-table/ng-table.css',
|
||||
'bower_components/ng-table/dist/ng-table.css',
|
||||
'bower_components/angularjs-toaster/toaster.css',
|
||||
'bower_components/angular-ui-select/dist/select.css',
|
||||
'lemur/static/app/styles/lemur.css'
|
||||
|
@ -24,6 +24,7 @@ from lemur.defaults.views import mod as defaults_bp
|
||||
from lemur.plugins.views import mod as plugins_bp
|
||||
from lemur.notifications.views import mod as notifications_bp
|
||||
from lemur.sources.views import mod as sources_bp
|
||||
from lemur.endpoints.views import mod as endpoints_bp
|
||||
|
||||
from lemur.__about__ import (
|
||||
__author__, __copyright__, __email__, __license__, __summary__, __title__,
|
||||
@ -47,7 +48,8 @@ LEMUR_BLUEPRINTS = (
|
||||
defaults_bp,
|
||||
plugins_bp,
|
||||
notifications_bp,
|
||||
sources_bp
|
||||
sources_bp,
|
||||
endpoints_bp
|
||||
)
|
||||
|
||||
|
||||
|
@ -98,16 +98,6 @@ def create(**kwargs):
|
||||
else:
|
||||
kwargs['roles'] = roles
|
||||
|
||||
if kwargs['type'] == 'subca':
|
||||
description = "This is the ROOT certificate for the {0} sub certificate authority the parent \
|
||||
authority is {1}.".format(kwargs.get('name'), kwargs.get('parent'))
|
||||
else:
|
||||
description = "This is the ROOT certificate for the {0} certificate authority.".format(
|
||||
kwargs.get('name')
|
||||
)
|
||||
|
||||
kwargs['description'] = description
|
||||
|
||||
cert = upload(**kwargs)
|
||||
kwargs['authority_certificate'] = cert
|
||||
|
||||
|
@ -9,8 +9,10 @@ import datetime
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from sqlalchemy import event, Integer, ForeignKey, String, DateTime, PassiveDefault, func, Column, Text, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql.expression import case
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy import event, Integer, ForeignKey, String, DateTime, PassiveDefault, func, Column, Text, Boolean
|
||||
|
||||
from lemur.database import db
|
||||
from lemur.models import certificate_associations, certificate_source_associations, \
|
||||
@ -73,6 +75,8 @@ class Certificate(db.Model):
|
||||
secondaryjoin=id == certificate_replacement_associations.c.replaced_certificate_id, # noqa
|
||||
backref='replaced')
|
||||
|
||||
endpoints = relationship("Endpoint", backref='certificate')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
cert = defaults.parse_certificate(kwargs['body'])
|
||||
|
||||
@ -104,22 +108,33 @@ class Certificate(db.Model):
|
||||
for domain in defaults.domains(cert):
|
||||
self.domains.append(Domain(name=domain))
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
if self.not_after < datetime.datetime.now():
|
||||
@hybrid_property
|
||||
def expired(self):
|
||||
if self.not_after <= datetime.datetime.now():
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_unused(self):
|
||||
if self.elb_listeners.count() == 0:
|
||||
@expired.expression
|
||||
def expired(cls):
|
||||
return case(
|
||||
[
|
||||
(cls.now_after <= datetime.datetime.now(), True)
|
||||
],
|
||||
else_=False
|
||||
)
|
||||
|
||||
@hybrid_property
|
||||
def revoked(self):
|
||||
if 'revoked' == self.status:
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_revoked(self):
|
||||
# we might not yet know the condition of the cert
|
||||
if self.status:
|
||||
if 'revoked' in self.status:
|
||||
return True
|
||||
@revoked.expression
|
||||
def revoked(cls):
|
||||
return case(
|
||||
[
|
||||
(cls.status == 'revoked', True)
|
||||
],
|
||||
else_=False
|
||||
)
|
||||
|
||||
def get_arn(self, account_number):
|
||||
"""
|
||||
|
@ -10,7 +10,7 @@ from marshmallow import fields, validates_schema, post_load
|
||||
from marshmallow.exceptions import ValidationError
|
||||
|
||||
from lemur.schemas import AssociatedAuthoritySchema, AssociatedDestinationSchema, AssociatedCertificateSchema, \
|
||||
AssociatedNotificationSchema, PluginInputSchema, ExtensionSchema, AssociatedRoleSchema
|
||||
AssociatedNotificationSchema, PluginInputSchema, ExtensionSchema, AssociatedRoleSchema, EndpointNestedOutputSchema
|
||||
|
||||
from lemur.authorities.schemas import AuthorityNestedOutputSchema
|
||||
from lemur.destinations.schemas import DestinationNestedOutputSchema
|
||||
@ -120,7 +120,7 @@ class CertificateOutputSchema(LemurOutputSchema):
|
||||
replaces = fields.Nested(CertificateNestedOutputSchema, many=True)
|
||||
authority = fields.Nested(AuthorityNestedOutputSchema)
|
||||
roles = fields.Nested(RoleNestedOutputSchema, many=True)
|
||||
endpoints = fields.List(fields.Dict(), missing=[])
|
||||
endpoints = fields.Nested(EndpointNestedOutputSchema, many=True, missing=[])
|
||||
|
||||
|
||||
class CertificateUploadInputSchema(CertificateSchema):
|
||||
|
@ -177,6 +177,7 @@ def upload(**kwargs):
|
||||
"""
|
||||
Allows for pre-made certificates to be imported into Lemur.
|
||||
"""
|
||||
from lemur.users import service as user_service
|
||||
roles = create_certificate_roles(**kwargs)
|
||||
|
||||
if kwargs.get('roles'):
|
||||
@ -187,10 +188,14 @@ def upload(**kwargs):
|
||||
cert = Certificate(**kwargs)
|
||||
|
||||
cert = database.create(cert)
|
||||
g.user.certificates.append(cert)
|
||||
|
||||
database.update(cert)
|
||||
return cert
|
||||
try:
|
||||
g.user.certificates.append(cert)
|
||||
except AttributeError:
|
||||
user = user_service.get_by_email('lemur@nobody')
|
||||
user.certificates.append(cert)
|
||||
|
||||
return database.update(cert)
|
||||
|
||||
|
||||
def create(**kwargs):
|
||||
|
@ -132,7 +132,10 @@ def bitstrength(cert):
|
||||
:param cert:
|
||||
:return: Integer
|
||||
"""
|
||||
return cert.public_key().key_size
|
||||
try:
|
||||
return cert.public_key().key_size
|
||||
except AttributeError:
|
||||
current_app.logger.debug('Unable to get bitstrength.')
|
||||
|
||||
|
||||
def issuer(cert):
|
||||
|
0
lemur/endpoints/__init__.py
Normal file
0
lemur/endpoints/__init__.py
Normal file
80
lemur/endpoints/models.py
Normal file
80
lemur/endpoints/models.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""
|
||||
.. module: lemur.endpoints.models
|
||||
:platform: unix
|
||||
:synopsis: This module contains all of the models need to create a authority within Lemur.
|
||||
:copyright: (c) 2015 by Netflix Inc., see AUTHORS for more
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy import Column, Integer, String, func, DateTime, PassiveDefault, Boolean, ForeignKey
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.sql.expression import case
|
||||
|
||||
from lemur.database import db
|
||||
|
||||
from lemur.models import policies_ciphers
|
||||
|
||||
|
||||
BAD_CIPHERS = [
|
||||
'Protocol-SSLv3',
|
||||
'Protocol-SSLv2',
|
||||
'Protocol-TLSv1'
|
||||
]
|
||||
|
||||
|
||||
class Cipher(db.Model):
|
||||
__tablename__ = 'ciphers'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(128), nullable=False)
|
||||
|
||||
@hybrid_property
|
||||
def deprecated(self):
|
||||
return self.name in BAD_CIPHERS
|
||||
|
||||
@deprecated.expression
|
||||
def deprecated(cls):
|
||||
return case(
|
||||
[
|
||||
(cls.name in BAD_CIPHERS, True)
|
||||
],
|
||||
else_=False
|
||||
)
|
||||
|
||||
|
||||
class Policy(db.Model):
|
||||
___tablename__ = 'policies'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(128), nullable=True)
|
||||
ciphers = relationship('Cipher', secondary=policies_ciphers, backref='policy')
|
||||
|
||||
|
||||
class Endpoint(db.Model):
|
||||
__tablename__ = 'endpoints'
|
||||
id = Column(Integer, primary_key=True)
|
||||
owner = Column(String(128))
|
||||
name = Column(String(128))
|
||||
dnsname = Column(String(256))
|
||||
type = Column(String(128))
|
||||
active = Column(Boolean, default=True)
|
||||
port = Column(Integer)
|
||||
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
|
||||
policy_id = Column(Integer, ForeignKey('policy.id'))
|
||||
policy = relationship('Policy', backref='endpoint')
|
||||
certificate_id = Column(Integer, ForeignKey('certificates.id'))
|
||||
|
||||
@property
|
||||
def issues(self):
|
||||
issues = []
|
||||
|
||||
for cipher in self.policy.ciphers:
|
||||
if cipher.deprecated:
|
||||
issues.append({'name': 'deprecated cipher', 'value': '{0} has been deprecated consider removing it.'.format(cipher.name)})
|
||||
|
||||
if self.certificate.expired:
|
||||
issues.append({'name': 'expired certificate', 'value': 'There is an expired certificate attached to this endpoint consider replacing it.'})
|
||||
|
||||
if self.certificate.revoked:
|
||||
issues.append({'name': 'revoked', 'value': 'There is a revoked certificate attached to this endpoint consider replacing it.'})
|
||||
|
||||
return issues
|
43
lemur/endpoints/schemas.py
Normal file
43
lemur/endpoints/schemas.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""
|
||||
.. module: lemur.endpoints.schemas
|
||||
: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 marshmallow import fields
|
||||
|
||||
from lemur.common.schema import LemurOutputSchema
|
||||
from lemur.certificates.schemas import CertificateNestedOutputSchema
|
||||
|
||||
|
||||
class CipherNestedOutputSchema(LemurOutputSchema):
|
||||
__envelope__ = False
|
||||
id = fields.Integer()
|
||||
deprecated = fields.Boolean()
|
||||
name = fields.String()
|
||||
|
||||
|
||||
class PolicyNestedOutputSchema(LemurOutputSchema):
|
||||
__envelope__ = False
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
ciphers = fields.Nested(CipherNestedOutputSchema, many=True)
|
||||
|
||||
|
||||
class EndpointOutputSchema(LemurOutputSchema):
|
||||
id = fields.Integer()
|
||||
description = fields.String()
|
||||
name = fields.String()
|
||||
dnsname = fields.String()
|
||||
owner = fields.Email()
|
||||
type = fields.String()
|
||||
port = fields.Integer()
|
||||
active = fields.Boolean()
|
||||
certificate = fields.Nested(CertificateNestedOutputSchema)
|
||||
policy = fields.Nested(PolicyNestedOutputSchema)
|
||||
|
||||
issues = fields.List(fields.Dict())
|
||||
|
||||
endpoint_output_schema = EndpointOutputSchema()
|
||||
endpoints_output_schema = EndpointOutputSchema(many=True)
|
144
lemur/endpoints/service.py
Normal file
144
lemur/endpoints/service.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""
|
||||
.. module: lemur.endpoints.service
|
||||
:platform: Unix
|
||||
:synopsis: This module contains all of the services level functions used to
|
||||
administer endpoints in Lemur
|
||||
: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 import g
|
||||
|
||||
from lemur import database
|
||||
from lemur.extensions import metrics
|
||||
from lemur.endpoints.models import Endpoint, Policy, Cipher
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
def get_all():
|
||||
"""
|
||||
Get all endpoints that are currently in Lemur.
|
||||
|
||||
:rtype : List
|
||||
:return:
|
||||
"""
|
||||
query = database.session_query(Endpoint)
|
||||
return database.find_all(query, Endpoint, {}).all()
|
||||
|
||||
|
||||
def get(endpoint_id):
|
||||
"""
|
||||
Retrieves an endpoint given it's ID
|
||||
|
||||
:param endpoint_id:
|
||||
:return:
|
||||
"""
|
||||
return database.get(Endpoint, endpoint_id)
|
||||
|
||||
|
||||
def get_by_dnsname(endpoint_dnsname):
|
||||
"""
|
||||
Retrieves an endpoint given it's name.
|
||||
|
||||
:param endpoint_dnsname:
|
||||
:return:
|
||||
"""
|
||||
return database.get(Endpoint, endpoint_dnsname, field='dnsname')
|
||||
|
||||
|
||||
def create(**kwargs):
|
||||
"""
|
||||
Creates a new endpoint.
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
endpoint = Endpoint(**kwargs)
|
||||
database.create(endpoint)
|
||||
metrics.send('endpoint_added', 'counter', 1)
|
||||
return endpoint
|
||||
|
||||
|
||||
def get_or_create_policy(**kwargs):
|
||||
policy = database.get(Policy, kwargs['name'], field='name')
|
||||
|
||||
if not policy:
|
||||
policy = Policy(**kwargs)
|
||||
database.create(policy)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def get_or_create_cipher(**kwargs):
|
||||
cipher = database.get(Cipher, kwargs['name'], field='name')
|
||||
|
||||
if not cipher:
|
||||
cipher = Cipher(**kwargs)
|
||||
database.create(cipher)
|
||||
|
||||
return cipher
|
||||
|
||||
|
||||
def update(endpoint_id, **kwargs):
|
||||
endpoint = database.get(Endpoint, endpoint_id)
|
||||
|
||||
endpoint.policy = kwargs['policy']
|
||||
endpoint.certificate = kwargs['certificate']
|
||||
database.update(endpoint)
|
||||
return endpoint
|
||||
|
||||
|
||||
def render(args):
|
||||
"""
|
||||
Helper that helps us render the REST Api responses.
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
query = database.session_query(Endpoint)
|
||||
filt = args.pop('filter')
|
||||
|
||||
if filt:
|
||||
terms = filt.split(';')
|
||||
if 'active' in filt: # this is really weird but strcmp seems to not work here??
|
||||
query = query.filter(Endpoint.active == terms[1])
|
||||
elif 'port' in filt:
|
||||
if terms[1] != 'null': # ng-table adds 'null' if a number is removed
|
||||
query = query.filter(Endpoint.port == terms[1])
|
||||
elif 'ciphers' in filt:
|
||||
query = query.filter(
|
||||
Cipher.name == terms[1]
|
||||
)
|
||||
else:
|
||||
query = database.filter(query, Endpoint, terms)
|
||||
|
||||
# we make sure that a user can only use an endpoint they either own are are a member of - admins can see all
|
||||
if not g.current_user.is_admin:
|
||||
endpoint_ids = []
|
||||
for role in g.current_user.roles:
|
||||
for endpoint in role.endpoints:
|
||||
endpoint_ids.append(endpoint.id)
|
||||
query = query.filter(Endpoint.id.in_(endpoint_ids))
|
||||
|
||||
return database.sort_and_page(query, Endpoint, args)
|
||||
|
||||
|
||||
def stats(**kwargs):
|
||||
"""
|
||||
Helper that defines some useful statistics about endpoints.
|
||||
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
attr = getattr(Endpoint, kwargs.get('metric'))
|
||||
query = database.db.session.query(attr, func.count(attr))
|
||||
|
||||
items = query.group_by(attr).all()
|
||||
|
||||
keys = []
|
||||
values = []
|
||||
for key, count in items:
|
||||
keys.append(key)
|
||||
values.append(count)
|
||||
|
||||
return {'labels': keys, 'values': values}
|
106
lemur/endpoints/views.py
Normal file
106
lemur/endpoints/views.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""
|
||||
.. module: lemur.endpoints.views
|
||||
: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 import Blueprint
|
||||
from flask.ext.restful import reqparse, Api
|
||||
|
||||
from lemur.common.utils import paginated_parser
|
||||
from lemur.common.schema import validate_schema
|
||||
from lemur.auth.service import AuthenticatedResource
|
||||
|
||||
from lemur.endpoints import service
|
||||
from lemur.endpoints.schemas import endpoint_output_schema, endpoints_output_schema
|
||||
|
||||
|
||||
mod = Blueprint('endpoints', __name__)
|
||||
api = Api(mod)
|
||||
|
||||
|
||||
class EndpointsList(AuthenticatedResource):
|
||||
""" Defines the 'endpoints' endpoint """
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(EndpointsList, self).__init__()
|
||||
|
||||
@validate_schema(None, endpoints_output_schema)
|
||||
def get(self):
|
||||
"""
|
||||
.. http:get:: /endpoints
|
||||
|
||||
The current list of endpoints
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /endpoints HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
|
||||
:query sortBy: field to sort on
|
||||
:query sortDir: acs or desc
|
||||
:query page: int default is 1
|
||||
:query filter: key value pair. format is k;v
|
||||
:query limit: limit number default is 10
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
|
||||
:note: this will only show certificates that the current user is authorized to use
|
||||
"""
|
||||
parser = paginated_parser.copy()
|
||||
args = parser.parse_args()
|
||||
return service.render(args)
|
||||
|
||||
|
||||
class Endpoints(AuthenticatedResource):
|
||||
def __init__(self):
|
||||
self.reqparse = reqparse.RequestParser()
|
||||
super(Endpoints, self).__init__()
|
||||
|
||||
@validate_schema(None, endpoint_output_schema)
|
||||
def get(self, endpoint_id):
|
||||
"""
|
||||
.. http:get:: /endpoints/1
|
||||
|
||||
One endpoint
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /endpoints/1 HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
|
||||
:reqheader Authorization: OAuth token to authenticate
|
||||
:statuscode 200: no error
|
||||
:statuscode 403: unauthenticated
|
||||
"""
|
||||
return service.get(endpoint_id)
|
||||
|
||||
|
||||
api.add_resource(EndpointsList, '/endpoints', endpoint='endpoints')
|
||||
api.add_resource(Endpoints, '/endpoints/<int:endpoint_id>', endpoint='endpoint')
|
@ -14,7 +14,7 @@ import imp
|
||||
import errno
|
||||
import pkg_resources
|
||||
|
||||
from logging import Formatter
|
||||
from logging import Formatter, StreamHandler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from flask import Flask
|
||||
@ -144,6 +144,10 @@ def configure_logging(app):
|
||||
app.logger.setLevel(app.config.get('LOG_LEVEL', 'DEBUG'))
|
||||
app.logger.addHandler(handler)
|
||||
|
||||
stream_handler = StreamHandler()
|
||||
stream_handler.setLevel(app.config.get('LOG_LEVEL'))
|
||||
app.logger.addHandler(stream_handler)
|
||||
|
||||
|
||||
def install_plugins(app):
|
||||
"""
|
||||
|
@ -189,7 +189,8 @@ def generate_settings():
|
||||
|
||||
|
||||
@manager.option('-s', '--sources', dest='labels')
|
||||
def sync(labels):
|
||||
@manager.option('-t', '--type', dest='type')
|
||||
def sync(labels, type):
|
||||
"""
|
||||
Attempts to run several methods Certificate discovery. This is
|
||||
run on a periodic basis and updates the Lemur datastore with the
|
||||
@ -212,7 +213,7 @@ def sync(labels):
|
||||
|
||||
while not sync_lock.i_am_locking():
|
||||
try:
|
||||
sync_lock.acquire(timeout=10) # wait up to 10 seconds
|
||||
sync_lock.acquire(timeout=2) # wait up to 10 seconds
|
||||
|
||||
sys.stdout.write("[+] Staring to sync sources: {labels}!\n".format(labels=labels))
|
||||
labels = labels.split(",")
|
||||
@ -220,7 +221,7 @@ def sync(labels):
|
||||
if labels[0] == 'all':
|
||||
source_sync()
|
||||
else:
|
||||
source_sync(labels=labels)
|
||||
source_sync(labels=labels, type=type)
|
||||
|
||||
sys.stdout.write(
|
||||
"[+] Finished syncing sources. Run Time: {time}\n".format(
|
||||
|
48
lemur/migrations/versions/368320d26c6c_.py
Normal file
48
lemur/migrations/versions/368320d26c6c_.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 368320d26c6c
|
||||
Revises: 3307381f3b88
|
||||
Create Date: 2016-05-27 13:41:47.413694
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '368320d26c6c'
|
||||
down_revision = '3307381f3b88'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlalchemy_utils
|
||||
|
||||
|
||||
def upgrade():
|
||||
### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('endpoints',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('owner', sa.String(length=128), nullable=True),
|
||||
sa.Column('name', sa.String(length=128), nullable=True),
|
||||
sa.Column('dnsname', sa.String(length=256), nullable=True),
|
||||
sa.Column('type', sa.String(length=128), nullable=True),
|
||||
sa.Column('active', sa.Boolean(), nullable=True),
|
||||
sa.Column('port', sa.Integer(), nullable=True),
|
||||
sa.Column('date_created', sa.DateTime(), server_default=sa.text(u'now()'), nullable=False),
|
||||
sa.Column('certificate_id', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('policy',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('endpoint_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(length=32), nullable=True),
|
||||
sa.Column('ciphers', sqlalchemy_utils.types.json.JSONType(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['endpoint_id'], ['endpoints.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('policy')
|
||||
op.drop_table('endpoints')
|
||||
### end Alembic commands ###
|
@ -63,9 +63,9 @@ roles_authorities = db.Table('roles_authorities',
|
||||
Index('roles_authorities_ix', roles_authorities.c.authority_id, roles_authorities.c.role_id)
|
||||
|
||||
roles_certificates = db.Table('roles_certificates',
|
||||
Column('certificate_id', Integer, ForeignKey('certificates.id')),
|
||||
Column('role_id', Integer, ForeignKey('roles.id'))
|
||||
)
|
||||
Column('certificate_id', Integer, ForeignKey('certificates.id')),
|
||||
Column('role_id', Integer, ForeignKey('roles.id'))
|
||||
)
|
||||
|
||||
Index('roles_certificates_ix', roles_certificates.c.certificate_id, roles_certificates.c.role_id)
|
||||
|
||||
@ -76,3 +76,10 @@ roles_users = db.Table('roles_users',
|
||||
)
|
||||
|
||||
Index('roles_users_ix', roles_users.c.user_id, roles_users.c.role_id)
|
||||
|
||||
|
||||
policies_ciphers = db.Table('policies_ciphers',
|
||||
Column('cipher_id', Integer, ForeignKey('ciphers.id')),
|
||||
Column('policy_id', Integer, ForeignKey('policy.id')))
|
||||
|
||||
Index('policies_ciphers_ix', policies_ciphers.c.cipher_id, policies_ciphers.c.policy_id)
|
||||
|
@ -45,6 +45,7 @@ def _get_message_data(cert):
|
||||
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
|
||||
|
||||
|
23
lemur/plugins/lemur_aws/ec2.py
Normal file
23
lemur/plugins/lemur_aws/ec2.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""
|
||||
.. module: lemur.plugins.lemur_aws.elb
|
||||
:synopsis: Module contains some often used and helpful classes that
|
||||
are used to deal with ELBs
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from lemur.plugins.lemur_aws.sts import sts_client
|
||||
|
||||
|
||||
@sts_client('ec2')
|
||||
def get_regions(**kwargs):
|
||||
regions = kwargs['client'].describe_regions()
|
||||
return [x['RegionName'] for x in regions['Regions']]
|
||||
|
||||
|
||||
@sts_client('ec2')
|
||||
def get_all_instances(**kwargs):
|
||||
"""
|
||||
Fetches all instance objects for a given account and region.
|
||||
"""
|
||||
paginator = kwargs['client'].get_paginator('describe_instances')
|
||||
return paginator.paginate()
|
@ -5,12 +5,10 @@
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import boto.ec2
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from lemur.exceptions import InvalidListener
|
||||
from lemur.plugins.lemur_aws.sts import assume_service
|
||||
from lemur.plugins.lemur_aws.sts import sts_client, assume_service
|
||||
|
||||
|
||||
def is_valid(listener_tuple):
|
||||
@ -38,41 +36,34 @@ def is_valid(listener_tuple):
|
||||
return listener_tuple
|
||||
|
||||
|
||||
def get_all_regions():
|
||||
"""
|
||||
Retrieves all current EC2 regions.
|
||||
|
||||
:return:
|
||||
"""
|
||||
regions = []
|
||||
for r in boto.ec2.regions():
|
||||
regions.append(r.name)
|
||||
return regions
|
||||
|
||||
|
||||
def get_all_elbs(account_number, region):
|
||||
@sts_client('elb')
|
||||
def get_all_elbs(**kwargs):
|
||||
"""
|
||||
Fetches all elb objects for a given account and region.
|
||||
|
||||
:param account_number:
|
||||
:param region:
|
||||
"""
|
||||
marker = None
|
||||
elbs = []
|
||||
return assume_service(account_number, 'elb', region).get_all_load_balancers()
|
||||
# TODO create pull request for boto to include elb marker support
|
||||
# while True:
|
||||
# app.logger.debug(response.__dict__)
|
||||
# raise Exception
|
||||
# result = response['list_server_certificates_response']['list_server_certificates_result']
|
||||
#
|
||||
# for elb in result['server_certificate_metadata_list']:
|
||||
# elbs.append(elb)
|
||||
#
|
||||
# if result['is_truncated'] == 'true':
|
||||
# marker = result['marker']
|
||||
# else:
|
||||
# return elbs
|
||||
return kwargs['client'].describe_load_balancers()
|
||||
|
||||
|
||||
@sts_client('elb')
|
||||
def describe_load_balancer_policies(load_balancer_name, policy_names, **kwargs):
|
||||
"""
|
||||
Fetching all policies currently associated with an ELB.
|
||||
|
||||
:param load_balancer_name:
|
||||
:return:
|
||||
"""
|
||||
return kwargs['client'].describe_load_balancer_policies(LoadBalancerName=load_balancer_name, PolicyNames=policy_names)
|
||||
|
||||
|
||||
@sts_client('elb')
|
||||
def describe_load_balancer_types(policies, **kwargs):
|
||||
"""
|
||||
Describe the policies with policy details.
|
||||
|
||||
:param policies:
|
||||
:return:
|
||||
"""
|
||||
return kwargs['client'].describe_load_balancer_policy_types(PolicyTypeNames=policies)
|
||||
|
||||
|
||||
def attach_certificate(account_number, region, name, port, certificate_id):
|
||||
@ -89,67 +80,67 @@ def attach_certificate(account_number, region, name, port, certificate_id):
|
||||
return assume_service(account_number, 'elb', region).set_lb_listener_SSL_certificate(name, port, certificate_id)
|
||||
|
||||
|
||||
def create_new_listeners(account_number, region, name, listeners=None):
|
||||
"""
|
||||
Creates a new listener and attaches it to the ELB.
|
||||
|
||||
:param account_number:
|
||||
:param region:
|
||||
:param name:
|
||||
:param listeners:
|
||||
:return:
|
||||
"""
|
||||
listeners = [is_valid(x) for x in listeners]
|
||||
return assume_service(account_number, 'elb', region).create_load_balancer_listeners(name, listeners=listeners)
|
||||
|
||||
|
||||
def update_listeners(account_number, region, name, listeners, ports):
|
||||
"""
|
||||
We assume that a listener with a specified port already exists. We can then
|
||||
delete the old listener on the port and create a new one in it's place.
|
||||
|
||||
If however we are replacing a listener e.g. changing a port from 80 to 443 we need
|
||||
to make sure we kept track of which ports we needed to delete so that we don't create
|
||||
two listeners (one 80 and one 443)
|
||||
|
||||
:param account_number:
|
||||
:param region:
|
||||
:param name:
|
||||
:param listeners:
|
||||
:param ports:
|
||||
"""
|
||||
# you cannot update a listeners port/protocol instead we remove the only one and
|
||||
# create a new one in it's place
|
||||
listeners = [is_valid(x) for x in listeners]
|
||||
|
||||
assume_service(account_number, 'elb', region).delete_load_balancer_listeners(name, ports)
|
||||
return create_new_listeners(account_number, region, name, listeners=listeners)
|
||||
|
||||
|
||||
def delete_listeners(account_number, region, name, ports):
|
||||
"""
|
||||
Deletes a listener from an ELB.
|
||||
|
||||
:param account_number:
|
||||
:param region:
|
||||
:param name:
|
||||
:param ports:
|
||||
:return:
|
||||
"""
|
||||
return assume_service(account_number, 'elb', region).delete_load_balancer_listeners(name, ports)
|
||||
|
||||
|
||||
def get_listeners(account_number, region, name):
|
||||
"""
|
||||
Gets the listeners configured on an elb and returns a array of tuples
|
||||
|
||||
:param account_number:
|
||||
:param region:
|
||||
:param name:
|
||||
:return: list of tuples
|
||||
"""
|
||||
|
||||
conn = assume_service(account_number, 'elb', region)
|
||||
elbs = conn.get_all_load_balancers(load_balancer_names=[name])
|
||||
if elbs:
|
||||
return elbs[0].listeners
|
||||
# def create_new_listeners(account_number, region, name, listeners=None):
|
||||
# """
|
||||
# Creates a new listener and attaches it to the ELB.
|
||||
#
|
||||
# :param account_number:
|
||||
# :param region:
|
||||
# :param name:
|
||||
# :param listeners:
|
||||
# :return:
|
||||
# """
|
||||
# listeners = [is_valid(x) for x in listeners]
|
||||
# return assume_service(account_number, 'elb', region).create_load_balancer_listeners(name, listeners=listeners)
|
||||
#
|
||||
#
|
||||
# def update_listeners(account_number, region, name, listeners, ports):
|
||||
# """
|
||||
# We assume that a listener with a specified port already exists. We can then
|
||||
# delete the old listener on the port and create a new one in it's place.
|
||||
#
|
||||
# If however we are replacing a listener e.g. changing a port from 80 to 443 we need
|
||||
# to make sure we kept track of which ports we needed to delete so that we don't create
|
||||
# two listeners (one 80 and one 443)
|
||||
#
|
||||
# :param account_number:
|
||||
# :param region:
|
||||
# :param name:
|
||||
# :param listeners:
|
||||
# :param ports:
|
||||
# """
|
||||
# # you cannot update a listeners port/protocol instead we remove the only one and
|
||||
# # create a new one in it's place
|
||||
# listeners = [is_valid(x) for x in listeners]
|
||||
#
|
||||
# assume_service(account_number, 'elb', region).delete_load_balancer_listeners(name, ports)
|
||||
# return create_new_listeners(account_number, region, name, listeners=listeners)
|
||||
#
|
||||
#
|
||||
# def delete_listeners(account_number, region, name, ports):
|
||||
# """
|
||||
# Deletes a listener from an ELB.
|
||||
#
|
||||
# :param account_number:
|
||||
# :param region:
|
||||
# :param name:
|
||||
# :param ports:
|
||||
# :return:
|
||||
# """
|
||||
# return assume_service(account_number, 'elb', region).delete_load_balancer_listeners(name, ports)
|
||||
#
|
||||
#
|
||||
# def get_listeners(account_number, region, name):
|
||||
# """
|
||||
# Gets the listeners configured on an elb and returns a array of tuples
|
||||
#
|
||||
# :param account_number:
|
||||
# :param region:
|
||||
# :param name:
|
||||
# :return: list of tuples
|
||||
# """
|
||||
#
|
||||
# conn = assume_service(account_number, 'elb', region)
|
||||
# elbs = conn.get_all_load_balancers(load_balancer_names=[name])
|
||||
# if elbs:
|
||||
# return elbs[0].listeners
|
||||
|
@ -6,18 +6,16 @@
|
||||
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from flask import current_app
|
||||
from boto.exception import BotoServerError
|
||||
|
||||
from lemur.plugins.bases import DestinationPlugin, SourcePlugin
|
||||
from lemur.plugins.lemur_aws import iam, elb
|
||||
from lemur.plugins.lemur_aws import iam
|
||||
from lemur.plugins.lemur_aws.ec2 import get_regions
|
||||
from lemur.plugins.lemur_aws.elb import get_all_elbs, describe_load_balancer_policies, attach_certificate
|
||||
from lemur.plugins import lemur_aws as aws
|
||||
|
||||
|
||||
def find_value(name, options):
|
||||
for o in options:
|
||||
if o['name'] == name:
|
||||
return o['value']
|
||||
|
||||
|
||||
class AWSDestinationPlugin(DestinationPlugin):
|
||||
title = 'AWS'
|
||||
slug = 'aws-destination'
|
||||
@ -45,14 +43,14 @@ class AWSDestinationPlugin(DestinationPlugin):
|
||||
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
|
||||
if private_key:
|
||||
try:
|
||||
iam.upload_cert(find_value('accountNumber', options), name, body, private_key, cert_chain=cert_chain)
|
||||
iam.upload_cert(self.get_option('accountNumber', options), name, body, private_key, cert_chain=cert_chain)
|
||||
except BotoServerError as e:
|
||||
if e.error_code != 'EntityAlreadyExists':
|
||||
raise Exception(e)
|
||||
|
||||
e = find_value('elb', options)
|
||||
e = self.get_option('elb', options)
|
||||
if e:
|
||||
elb.attach_certificate(kwargs['accountNumber'], ['region'], e['name'], e['port'], e['certificateId'])
|
||||
attach_certificate(kwargs['accountNumber'], ['region'], e['name'], e['port'], e['certificateId'])
|
||||
else:
|
||||
raise Exception("Unable to upload to AWS, private key is required")
|
||||
|
||||
@ -60,7 +58,7 @@ class AWSDestinationPlugin(DestinationPlugin):
|
||||
class AWSSourcePlugin(SourcePlugin):
|
||||
title = 'AWS'
|
||||
slug = 'aws-source'
|
||||
description = 'Discovers all SSL certificates in an AWS account'
|
||||
description = 'Discovers all SSL certificates and ELB endpoints in an AWS account'
|
||||
version = aws.VERSION
|
||||
|
||||
author = 'Kevin Glisson'
|
||||
@ -74,11 +72,16 @@ class AWSSourcePlugin(SourcePlugin):
|
||||
'validation': '/^[0-9]{12,12}$/',
|
||||
'helpMessage': 'Must be a valid AWS account number!',
|
||||
},
|
||||
{
|
||||
'name': 'regions',
|
||||
'type': 'str',
|
||||
'helpMessage': 'Comma separated list of regions to search in, if no region is specified we look in all regions.'
|
||||
},
|
||||
]
|
||||
|
||||
def get_certificates(self, options, **kwargs):
|
||||
certs = []
|
||||
arns = iam.get_all_server_certs(find_value('accountNumber', options))
|
||||
arns = iam.get_all_server_certs(self.get_option('accountNumber', options))
|
||||
for arn in arns:
|
||||
cert_body, cert_chain = iam.get_cert_from_arn(arn)
|
||||
cert_name = iam.get_name_from_arn(arn)
|
||||
@ -89,3 +92,57 @@ class AWSSourcePlugin(SourcePlugin):
|
||||
)
|
||||
certs.append(cert)
|
||||
return certs
|
||||
|
||||
def get_endpoints(self, options, **kwargs):
|
||||
endpoints = []
|
||||
account_number = self.get_option('accountNumber', options)
|
||||
regions = self.get_option('regions', options)
|
||||
|
||||
if not regions:
|
||||
regions = get_regions(account_number=account_number)
|
||||
else:
|
||||
regions = regions.split(',')
|
||||
|
||||
for region in regions:
|
||||
elbs = get_all_elbs(account_number=account_number, region=region)
|
||||
current_app.logger.info("Describing load balancers in {0}-{1}".format(account_number, region))
|
||||
for elb in elbs['LoadBalancerDescriptions']:
|
||||
for listener in elb['ListenerDescriptions']:
|
||||
if not listener['Listener'].get('SSLCertificateId'):
|
||||
continue
|
||||
|
||||
endpoint = dict(
|
||||
name=elb['LoadBalancerName'],
|
||||
dnsname=elb['DNSName'],
|
||||
type='elb',
|
||||
port=listener['Listener']['LoadBalancerPort'],
|
||||
certificate_name=iam.get_name_from_arn(listener['Listener']['SSLCertificateId'])
|
||||
)
|
||||
|
||||
if listener['PolicyNames']:
|
||||
policy = describe_load_balancer_policies(elb['LoadBalancerName'], listener['PolicyNames'], account_number=account_number, region=region)
|
||||
endpoint['policy'] = format_elb_cipher_policy(policy)
|
||||
|
||||
endpoints.append(endpoint)
|
||||
|
||||
return endpoints
|
||||
|
||||
|
||||
def format_elb_cipher_policy(policy):
|
||||
"""
|
||||
Attempts to format cipher policy information into a common format.
|
||||
:param policy:
|
||||
:return:
|
||||
"""
|
||||
ciphers = []
|
||||
name = None
|
||||
for descr in policy['PolicyDescriptions']:
|
||||
for attr in descr['PolicyAttributeDescriptions']:
|
||||
if attr['AttributeName'] == 'Reference-Security-Policy':
|
||||
name = attr['AttributeValue']
|
||||
continue
|
||||
|
||||
if attr['AttributeValue'] == 'true':
|
||||
ciphers.append(attr['AttributeName'])
|
||||
|
||||
return dict(name=name, ciphers=ciphers)
|
||||
|
@ -5,13 +5,16 @@
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
from functools import wraps
|
||||
|
||||
import boto
|
||||
import boto.ec2.elb
|
||||
import boto3
|
||||
|
||||
from flask import current_app
|
||||
|
||||
|
||||
def assume_service(account_number, service, region=None):
|
||||
def assume_service(account_number, service, region='us-east-1'):
|
||||
conn = boto.connect_sts()
|
||||
|
||||
role = conn.assume_role('arn:aws:iam::{0}:role/{1}'.format(
|
||||
@ -35,3 +38,40 @@ def assume_service(account_number, service, region=None):
|
||||
aws_access_key_id=role.credentials.access_key,
|
||||
aws_secret_access_key=role.credentials.secret_key,
|
||||
security_token=role.credentials.session_token)
|
||||
|
||||
|
||||
def sts_client(service, service_type='client'):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
sts = boto3.client('sts')
|
||||
arn = 'arn:aws:iam::{0}:role/{1}'.format(
|
||||
kwargs.pop('account_number'),
|
||||
current_app.config.get('LEMUR_INSTANCE_PROFILE', 'Lemur')
|
||||
)
|
||||
# TODO add user specific information to RoleSessionName
|
||||
role = sts.assume_role(RoleArn=arn, RoleSessionName='lemur')
|
||||
|
||||
if service_type == 'client':
|
||||
client = boto3.client(
|
||||
service,
|
||||
region_name=kwargs.pop('region', 'us-east-1'),
|
||||
aws_access_key_id=role['Credentials']['AccessKeyId'],
|
||||
aws_secret_access_key=role['Credentials']['SecretAccessKey'],
|
||||
aws_session_token=role['Credentials']['SessionToken']
|
||||
)
|
||||
kwargs['client'] = client
|
||||
elif service_type == 'resource':
|
||||
resource = boto3.resource(
|
||||
service,
|
||||
region_name=kwargs.pop('region', 'us-east-1'),
|
||||
aws_access_key_id=role['Credentials']['AccessKeyId'],
|
||||
aws_secret_access_key=role['Credentials']['SecretAccessKey'],
|
||||
aws_session_token=role['Credentials']['SessionToken']
|
||||
)
|
||||
kwargs['resource'] = resource
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
1
lemur/plugins/lemur_aws/tests/conftest.py
Normal file
1
lemur/plugins/lemur_aws/tests/conftest.py
Normal file
@ -0,0 +1 @@
|
||||
from lemur.tests.conftest import * # noqa
|
14
lemur/plugins/lemur_aws/tests/test_elb.py
Normal file
14
lemur/plugins/lemur_aws/tests/test_elb.py
Normal file
@ -0,0 +1,14 @@
|
||||
import boto
|
||||
from moto import mock_sts, mock_elb
|
||||
|
||||
|
||||
@mock_sts()
|
||||
@mock_elb()
|
||||
def test_get_all_elbs(app):
|
||||
from lemur.plugins.lemur_aws.elb import get_all_elbs
|
||||
conn = boto.ec2.elb.connect_to_region('us-east-1')
|
||||
elbs = get_all_elbs(account_number='123456789012', region='us-east-1')
|
||||
assert not elbs['LoadBalancerDescriptions']
|
||||
conn.create_load_balancer('example-lb', ['us-east-1a', 'us-east-1b'], [(443, 5443, 'tcp')])
|
||||
elbs = get_all_elbs(account_number='123456789012', region='us-east-1')
|
||||
assert elbs['LoadBalancerDescriptions']
|
0
lemur/plugins/lemur_aws/tests/test_plugin.py
Normal file
0
lemur/plugins/lemur_aws/tests/test_plugin.py
Normal file
@ -23,7 +23,7 @@
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:35px;color:#727272;" line-height:1.5">
|
||||
<td align="left" style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:35px;color:#727272; line-height:1.5">
|
||||
Lemur
|
||||
</td>
|
||||
</tr>
|
||||
@ -83,12 +83,15 @@
|
||||
<tr valign="middle">
|
||||
<td width="32px"></td>
|
||||
<td width="16px"></td>
|
||||
<td style="line-height:1.2"><span
|
||||
style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.name }}</span><br><span
|
||||
style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">{{ message.owner }}
|
||||
<td style="line-height:1.2">
|
||||
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:20px;color:#202020">{{ message.name }}</span>
|
||||
<br>
|
||||
<span style="font-family:Roboto-Regular,Helvetica,Arial,sans-serif;font-size:13px;color:#727272">
|
||||
{{ message.endpoints | length }} Endpoints
|
||||
<br>{{ message.owner }}
|
||||
<br>{{ message.not_after | time }}
|
||||
<a href="https://{{ hostname }}/#/certificates/{{ message.name }}" target="_blank">Details</a>
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% if not loop.last %}
|
||||
|
@ -210,3 +210,14 @@ class ExtensionSchema(BaseExtensionSchema):
|
||||
authority_key_identifier = fields.Nested(AuthorityKeyIdentifierSchema)
|
||||
certificate_info_access = fields.Nested(CertificateInfoAccessSchema)
|
||||
custom = fields.List(fields.Nested(CustomOIDSchema))
|
||||
|
||||
|
||||
class EndpointNestedOutputSchema(LemurOutputSchema):
|
||||
__envelope__ = False
|
||||
id = fields.Integer()
|
||||
description = fields.String()
|
||||
name = fields.String()
|
||||
dnsname = fields.String()
|
||||
owner = fields.Email()
|
||||
type = fields.String()
|
||||
active = fields.Boolean()
|
||||
|
@ -5,12 +5,15 @@
|
||||
:license: Apache, see LICENSE for more details.
|
||||
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from lemur import database
|
||||
from lemur.sources.models import Source
|
||||
from lemur.certificates.models import Certificate
|
||||
from lemur.certificates import service as cert_service
|
||||
from lemur.endpoints import service as endpoint_service
|
||||
from lemur.destinations import service as destination_service
|
||||
|
||||
from lemur.plugins.base import plugins
|
||||
@ -37,7 +40,7 @@ def _disassociate_certs_from_source(current_certificates, found_certificates, so
|
||||
c.sources.delete(s)
|
||||
|
||||
|
||||
def sync_create(certificate, source):
|
||||
def certificate_create(certificate, source):
|
||||
cert = cert_service.import_certificate(**certificate)
|
||||
cert.description = "This certificate was automatically discovered by Lemur"
|
||||
cert.sources.append(source)
|
||||
@ -45,7 +48,7 @@ def sync_create(certificate, source):
|
||||
database.update(cert)
|
||||
|
||||
|
||||
def sync_update(certificate, source):
|
||||
def certificate_update(certificate, source):
|
||||
for s in certificate.sources:
|
||||
if s.label == source.label:
|
||||
break
|
||||
@ -66,40 +69,102 @@ def sync_update_destination(certificate, source):
|
||||
certificate.destinations.append(dest)
|
||||
|
||||
|
||||
def sync(labels=None):
|
||||
def sync_endpoints(source):
|
||||
new, updated = 0, 0
|
||||
current_app.logger.debug("Retrieving endpoints from {0}".format(source.label))
|
||||
s = plugins.get(source.plugin_name)
|
||||
|
||||
try:
|
||||
endpoints = s.get_endpoints(source.options)
|
||||
except NotImplementedError:
|
||||
current_app.logger.warning("Unable to sync endpoints for source {0} plugin has not implemented 'get_endpoints'".format(source.label))
|
||||
return
|
||||
|
||||
for endpoint in endpoints:
|
||||
exists = endpoint_service.get_by_dnsname(endpoint['dnsname'])
|
||||
|
||||
certificate_name = endpoint.pop('certificate_name', None)
|
||||
certificate = endpoint.pop('certificate', None)
|
||||
|
||||
if certificate_name:
|
||||
cert = cert_service.get_by_name(certificate_name)
|
||||
|
||||
elif certificate:
|
||||
cert = cert_service.get_by_body(certificate['body'])
|
||||
if not cert:
|
||||
cert = cert_service.import_certificate(**certificate)
|
||||
|
||||
if not cert:
|
||||
current_app.logger.error("Unable to find associated certificate, be sure that certificates are sync'ed before endpoints")
|
||||
continue
|
||||
|
||||
endpoint['certificate'] = cert
|
||||
|
||||
policy = endpoint.pop('policy')
|
||||
|
||||
policy_ciphers = []
|
||||
for nc in policy['ciphers']:
|
||||
policy_ciphers.append(endpoint_service.get_or_create_cipher(name=nc))
|
||||
|
||||
policy['ciphers'] = policy_ciphers
|
||||
endpoint['policy'] = endpoint_service.get_or_create_policy(**policy)
|
||||
|
||||
if not exists:
|
||||
endpoint_service.create(**endpoint)
|
||||
new += 1
|
||||
|
||||
else:
|
||||
endpoint_service.update(exists.id, **endpoint)
|
||||
updated += 1
|
||||
|
||||
|
||||
def sync_certificates(source):
|
||||
new, updated = 0, 0
|
||||
c_certificates = cert_service.get_all_certs()
|
||||
|
||||
current_app.logger.debug("Retrieving certificates from {0}".format(source.label))
|
||||
s = plugins.get(source.plugin_name)
|
||||
certificates = s.get_certificates(source.options)
|
||||
|
||||
for certificate in certificates:
|
||||
exists = cert_service.find_duplicates(certificate['body'])
|
||||
|
||||
if not exists:
|
||||
certificate_create(certificate, source)
|
||||
new += 1
|
||||
|
||||
# check to make sure that existing certificates have the current source associated with it
|
||||
elif len(exists) == 1:
|
||||
certificate_update(exists[0], source)
|
||||
updated += 1
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
"Multiple certificates found, attempt to deduplicate the following certificates: {0}".format(
|
||||
",".join([x.name for x in exists])
|
||||
)
|
||||
)
|
||||
|
||||
# we need to try and find the absent of certificates so we can properly disassociate them when they are deleted
|
||||
_disassociate_certs_from_source(c_certificates, certificates, source)
|
||||
|
||||
|
||||
def sync(labels=None, type=None):
|
||||
for source in database.get_all(Source, True, field='active'):
|
||||
# we should be able to specify, individual sources to sync
|
||||
if labels:
|
||||
if source.label not in labels:
|
||||
continue
|
||||
|
||||
current_app.logger.debug("Retrieving certificates from {0}".format(source.label))
|
||||
s = plugins.get(source.plugin_name)
|
||||
certificates = s.get_certificates(source.options)
|
||||
if type == 'endpoints':
|
||||
sync_endpoints(source)
|
||||
elif type == 'certificates':
|
||||
sync_certificates(source)
|
||||
else:
|
||||
sync_certificates(source)
|
||||
sync_endpoints(source)
|
||||
|
||||
for certificate in certificates:
|
||||
exists = cert_service.find_duplicates(certificate['body'])
|
||||
|
||||
if not exists:
|
||||
sync_create(certificate, source)
|
||||
new += 1
|
||||
|
||||
# check to make sure that existing certificates have the current source associated with it
|
||||
elif len(exists) == 1:
|
||||
sync_update(exists[0], source)
|
||||
updated += 1
|
||||
else:
|
||||
current_app.logger.warning(
|
||||
"Multiple certificates found, attempt to deduplicate the following certificates: {0}".format(
|
||||
",".join([x.name for x in exists])
|
||||
)
|
||||
)
|
||||
|
||||
# we need to try and find the absent of certificates so we can properly disassociate them when they are deleted
|
||||
_disassociate_certs_from_source(c_certificates, certificates, source)
|
||||
source.last_run = datetime.datetime.utcnow()
|
||||
database.update(source)
|
||||
|
||||
|
||||
def create(label, plugin_name, options, description=None):
|
||||
|
@ -57,7 +57,7 @@
|
||||
</tr>
|
||||
<tr class="warning" ng-if="certificate.toggle" ng-repeat-end>
|
||||
<td colspan="12">
|
||||
<uib-tabset justified="true" class="col-md-6">
|
||||
<uib-tabset justified="true" class="col-md-8">
|
||||
<uib-tab>
|
||||
<uib-tab-heading>Basic Info</uib-tab-heading>
|
||||
<ul class="list-group">
|
||||
@ -114,6 +114,18 @@
|
||||
</li>
|
||||
</ul>
|
||||
</uib-tab>
|
||||
<uib-tab>
|
||||
<uib-tab-heading>Endpoints</uib-tab-heading>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item" ng-repeat="endpoint in certificate.endpoints">
|
||||
<span class="pull-right"><label class="label label-default">{{ endpoint.type }}</label></span>
|
||||
<ul class="list-unstyled">
|
||||
<li>{{ endpoint.name }}</li>
|
||||
<li><span class="text-muted">{{ endpoint.dnsname }}</span></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</uib-tab>
|
||||
<uib-tab>
|
||||
<uib-tab-heading>Notifications</uib-tab-heading>
|
||||
<ul class="list-group">
|
||||
@ -158,7 +170,7 @@
|
||||
</ul>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
<uib-tabset justified="true" class="col-md-6">
|
||||
<uib-tabset justified="true" class="col-md-4">
|
||||
<uib-tab>
|
||||
<uib-tab-heading>
|
||||
Chain
|
||||
|
21
lemur/static/app/angular/endpoints/services.js
vendored
Normal file
21
lemur/static/app/angular/endpoints/services.js
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
angular.module('lemur')
|
||||
.service('EndpointApi', function (LemurRestangular) {
|
||||
return LemurRestangular.all('endpoints');
|
||||
})
|
||||
.service('EndpointService', function ($location, EndpointApi) {
|
||||
var EndpointService = this;
|
||||
EndpointService.findEndpointsByName = function (filterValue) {
|
||||
return EndpointApi.getList({'filter[label]': filterValue})
|
||||
.then(function (endpoints) {
|
||||
return endpoints;
|
||||
});
|
||||
};
|
||||
|
||||
EndpointService.getCertificates = function (endpoint) {
|
||||
endpoint.getList('certificates').then(function (certificates) {
|
||||
endpoint.certificates = certificates;
|
||||
});
|
||||
};
|
||||
return EndpointService;
|
||||
});
|
47
lemur/static/app/angular/endpoints/view/view.js
vendored
Normal file
47
lemur/static/app/angular/endpoints/view/view.js
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
angular.module('lemur')
|
||||
|
||||
.config(function config($stateProvider) {
|
||||
$stateProvider.state('endpoints', {
|
||||
url: '/endpoints',
|
||||
templateUrl: '/angular/endpoints/view/view.tpl.html',
|
||||
controller: 'EndpointsViewController'
|
||||
});
|
||||
})
|
||||
|
||||
.controller('EndpointsViewController', function ($q, $scope, $uibModal, EndpointApi, EndpointService, MomentService, ngTableParams) {
|
||||
$scope.filter = {};
|
||||
$scope.endpointsTable = new ngTableParams({
|
||||
page: 1, // show first page
|
||||
count: 10, // count per page
|
||||
sorting: {
|
||||
id: 'desc' // initial sorting
|
||||
},
|
||||
filter: $scope.filter
|
||||
}, {
|
||||
total: 0, // length of data
|
||||
getData: function ($defer, params) {
|
||||
EndpointApi.getList(params.url()).then(
|
||||
function (data) {
|
||||
params.total(data.total);
|
||||
$defer.resolve(data);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
$scope.ciphers = [
|
||||
{'title': 'Protocol-SSLv2', 'id': 'Protocol-SSLv2'},
|
||||
{'title': 'Protocol-SSLv3', 'id': 'Protocol-SSLv3'},
|
||||
{'title': 'Protocol-TLSv1', 'id': 'Protocol-TLSv1'},
|
||||
{'title': 'Protocol-TLSv1.1', 'id': 'Protocol-TLSv1.1'},
|
||||
{'title': 'Protocol-TLSv1.1', 'id': 'Protocol-TLSv1.2'},
|
||||
];
|
||||
|
||||
$scope.momentService = MomentService;
|
||||
|
||||
$scope.endpointService = EndpointService;
|
||||
|
||||
});
|
108
lemur/static/app/angular/endpoints/view/view.tpl.html
Normal file
108
lemur/static/app/angular/endpoints/view/view.tpl.html
Normal file
@ -0,0 +1,108 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h2 class="featurette-heading">Endpoints
|
||||
<span class="text-muted"><small>443 or bust</small></span></h2>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="btn-group">
|
||||
<button ng-model="showFilter" class="btn btn-default" uib-btn-checkbox
|
||||
btn-checkbox-true="1"
|
||||
btn-checkbox-false="0">Filter</button>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table ng-table="endpointsTable" class="table table-striped" show-filter="showFilter" template-pagination="angular/pager.html" >
|
||||
<tbody>
|
||||
<tr ng-repeat-start="endpoint in $data track by $index">
|
||||
<td data-title="''" class="centered-cell">
|
||||
<i ng-if="endpoint.issues.length" class="fa fa-exclamation-triangle" uib-tooltip="Issues Found" tooltip-placement="right" style="color: #FF5154;" aria-hidden="true"></i>
|
||||
<i ng-if="!endpoint.issues.length" class="fa fa-check-circle" aria-hidden="true" uib-tooltip="No issues" tooltip-placement="right" style="color: #93c54b;"></i>
|
||||
</td>
|
||||
<td data-title="'Name'" sortable="'name'" filter="{ 'name': 'text' }">
|
||||
<ul class="list-unstyled">
|
||||
<li>{{ endpoint.name }}</li>
|
||||
<li><span class="text-muted">{{ endpoint.dnsname }}</span></li>
|
||||
</ul>
|
||||
</td>
|
||||
<td data-title="'Port'" sortable="'port'" filter="{ 'port': 'number' }" class="centered-cell">
|
||||
{{ endpoint.port }}
|
||||
</td>
|
||||
<td data-title="'Type'" sortable="'type'" filter="{ 'type': 'text'}" class="centered-cell">
|
||||
<ul class="list-unstyled">
|
||||
<li><label class="label label-default text-uppercase">{{ endpoint.type }}</label></li>
|
||||
</ul>
|
||||
</td>
|
||||
<td data-title="''" filter="{ 'ciphers' : 'select'}" filter-data="ciphers" class="centered-cell">
|
||||
<div class="btn-group-vertical pull-right">
|
||||
<button ng-model="endpoint.toggle" class="btn btn-sm btn-info" uib-btn-checkbox
|
||||
btn-checkbox-true="1"
|
||||
btn-checkbox-false="0">More
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="warning" ng-if="endpoint.toggle" ng-repeat-end>
|
||||
<td colspan="12">
|
||||
<uib-tabset justified="true" class="col-md-6">
|
||||
<uib-tab>
|
||||
<uib-tab-heading>Certificate</uib-tab-heading>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<strong>Name</strong>
|
||||
<span class="pull-right">
|
||||
{{ endpoint.certificate.name }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<strong>Not Before</strong>
|
||||
<span class="pull-right" uib-tooltip="{{ endpoint.certificate.notBefore }}">
|
||||
{{ momentService.createMoment(endpoint.certificate.notBefore) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<strong>Not After</strong>
|
||||
<span class="pull-right" uib-tooltip="{{ endpoint.certificate.notAfter }}">
|
||||
{{ momentService.createMoment(endpoint.certificate.notAfter) }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<strong>Description</strong>
|
||||
<p>{{ endpoint.certificate.description }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</uib-tab>
|
||||
<uib-tab>
|
||||
<uib-tab-heading>
|
||||
Issues
|
||||
</uib-tab-heading>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item" ng-repeat="issue in endpoint.issues">
|
||||
<ul class="list-unstyled">
|
||||
<li>{{ issue.name | titleCase }}</li>
|
||||
<li><span class="text-muted">{{ issue.value }}</span></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
<uib-tabset justified="true" class="col-md-6">
|
||||
<uib-tab>
|
||||
<uib-tab-heading>
|
||||
Ciphers/Protocols
|
||||
</uib-tab-heading>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item" ng-repeat="cipher in endpoint.policy.ciphers">
|
||||
<strong>{{ cipher.name }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -51,13 +51,14 @@
|
||||
<li><a ui-sref="dashboard">Dashboard</a></li>
|
||||
<li><a ui-sref="certificates">Certificates</a></li>
|
||||
<li><a ui-sref="authorities">Authorities</a></li>
|
||||
<li><a ui-sref="endpoints">Endpoints</a></li>
|
||||
<li><a ui-sref="notifications">Notifications</a></li>
|
||||
<li><a ui-sref="destinations">Destinations</a></li>
|
||||
<li><a ui-sref="sources">Sources</a></li>
|
||||
<li></li>
|
||||
<li class="dropdown" uib-dropdown on-toggle="toggled(open)">
|
||||
<a href class="dropdown-toggle" uib-dropdown-toggle>Settings <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ui-sref="destinations">Destinations</a></li>
|
||||
<li><a ui-sref="sources">Sources</a></li>
|
||||
<li><a ui-sref="roles">Roles</a></li>
|
||||
<li><a ui-sref="users">Users</a></li>
|
||||
<li><a ui-sref="domains">Domains</a></li>
|
||||
|
@ -198,3 +198,8 @@ a {
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.centered-cell {
|
||||
vertical-align:middle;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ from datetime import date
|
||||
|
||||
from factory import Sequence, post_generation, SubFactory
|
||||
from factory.alchemy import SQLAlchemyModelFactory
|
||||
from factory.fuzzy import FuzzyChoice, FuzzyText, FuzzyDate
|
||||
from factory.fuzzy import FuzzyChoice, FuzzyText, FuzzyDate, FuzzyInteger
|
||||
|
||||
|
||||
from lemur.database import db
|
||||
@ -210,3 +210,20 @@ class UserFactory(BaseFactory):
|
||||
if extracted:
|
||||
for authority in extracted:
|
||||
self.authorities.append(authority)
|
||||
|
||||
|
||||
class PolicyFactory(BaseFactory):
|
||||
"""Policy Factory."""
|
||||
name = Sequence(lambda n: 'endpoint{0}'.format(n))
|
||||
|
||||
|
||||
class EndpointFactory(BaseFactory):
|
||||
"""Endpoint Factory."""
|
||||
owner = 'joe@example.com'
|
||||
name = Sequence(lambda n: 'endpoint{0}'.format(n))
|
||||
type = FuzzyChoice(['elb'])
|
||||
active = True
|
||||
port = FuzzyInteger(0, high=65535)
|
||||
policy = SubFactory(PolicyFactory)
|
||||
certificate = SubFactory(CertificateFactory)
|
||||
destination = SubFactory(DestinationFactory)
|
||||
|
87
lemur/tests/test_endpoints.py
Normal file
87
lemur/tests/test_endpoints.py
Normal file
@ -0,0 +1,87 @@
|
||||
import pytest
|
||||
|
||||
from lemur.endpoints.views import * # noqa
|
||||
|
||||
|
||||
from .vectors import VALID_ADMIN_HEADER_TOKEN, VALID_USER_HEADER_TOKEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 404),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 404),
|
||||
('', 401)
|
||||
])
|
||||
def test_endpoint_get(client, token, status):
|
||||
assert client.get(api.url_for(Endpoints, endpoint_id=1), headers=token).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 405),
|
||||
('', 405)
|
||||
])
|
||||
def test_endpoint_post_(client, token, status):
|
||||
assert client.post(api.url_for(Endpoints, endpoint_id=1), data={}, headers=token).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 405),
|
||||
('', 405)
|
||||
])
|
||||
def test_endpoint_put(client, token, status):
|
||||
assert client.put(api.url_for(Endpoints, endpoint_id=1), data={}, headers=token).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 405),
|
||||
('', 405)
|
||||
])
|
||||
def test_endpoint_delete(client, token, status):
|
||||
assert client.delete(api.url_for(Endpoints, endpoint_id=1), headers=token).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 405),
|
||||
('', 405)
|
||||
])
|
||||
def test_endpoint_patch(client, token, status):
|
||||
assert client.patch(api.url_for(Endpoints, endpoint_id=1), data={}, headers=token).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 405),
|
||||
('', 405)
|
||||
])
|
||||
def test_endpoint_list_post_(client, token, status):
|
||||
assert client.post(api.url_for(EndpointsList), data={}, headers=token).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 200),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 200),
|
||||
('', 401)
|
||||
])
|
||||
def test_endpoint_list_get(client, token, status):
|
||||
assert client.get(api.url_for(EndpointsList), headers=token).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 405),
|
||||
('', 405)
|
||||
])
|
||||
def test_endpoint_list_delete(client, token, status):
|
||||
assert client.delete(api.url_for(EndpointsList), headers=token).status_code == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token,status", [
|
||||
(VALID_USER_HEADER_TOKEN, 405),
|
||||
(VALID_ADMIN_HEADER_TOKEN, 405),
|
||||
('', 405)
|
||||
])
|
||||
def test_endpoint_list_patch(client, token, status):
|
||||
assert client.patch(api.url_for(EndpointsList), data={}, headers=token).status_code == status
|
3
setup.py
3
setup.py
@ -47,7 +47,6 @@ install_requires = [
|
||||
'requests==2.9.1',
|
||||
'psycopg2==2.6.1',
|
||||
'arrow==0.7.0',
|
||||
'boto==2.38.0', # we might make this optional
|
||||
'six==1.10.0',
|
||||
'gunicorn==19.4.1',
|
||||
'marshmallow-sqlalchemy==0.8.0',
|
||||
@ -60,6 +59,8 @@ install_requires = [
|
||||
'lockfile==0.12.2',
|
||||
'inflection==0.3.1',
|
||||
'future==0.15.2',
|
||||
'boto==2.38.0', # we might make this optional
|
||||
'boto3==1.3.0'
|
||||
]
|
||||
|
||||
tests_require = [
|
||||
|
Loading…
Reference in New Issue
Block a user