Merge pull request #116 from kevgliss/algo
Adding the ability to track a certificates signing key algorithm
This commit is contained in:
commit
b20bdf3c4e
16
docs/faq.rst
16
docs/faq.rst
|
@ -14,6 +14,22 @@ I am seeing Lemur's javascript load in my browser but not the CSS.
|
||||||
:doc:`production/index` for example configurations.
|
:doc:`production/index` for example configurations.
|
||||||
|
|
||||||
|
|
||||||
|
Running 'lemur db upgrade' seems stuck.
|
||||||
|
Most likely, the upgrade is stuck because an existing query on the database is holding onto a lock that the
|
||||||
|
migration needs.
|
||||||
|
|
||||||
|
To resolve, login to your lemur database and run:
|
||||||
|
|
||||||
|
SELECT * FROM pg_locks l INNER JOIN pg_stat_activity s ON (l.pid = s.pid) WHERE waiting AND NOT granted;
|
||||||
|
|
||||||
|
This will give you a list of queries that are currently waiting to be executed. From there attempt to idenity the PID
|
||||||
|
of the query blocking the migration. Once found execute:
|
||||||
|
|
||||||
|
select pg_terminate_backend(<blocking-pid>);
|
||||||
|
|
||||||
|
See `<http://stackoverflow.com/questions/22896496/alembic-migration-stuck-with-postgresql>`_ for more.
|
||||||
|
|
||||||
|
|
||||||
How do I
|
How do I
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ from sqlalchemy import Column, Integer, String, Text, func, ForeignKey, DateTime
|
||||||
from sqlalchemy.dialects.postgresql import JSON
|
from sqlalchemy.dialects.postgresql import JSON
|
||||||
|
|
||||||
from lemur.database import db
|
from lemur.database import db
|
||||||
from lemur.certificates.models import cert_get_cn, cert_get_not_after, cert_get_not_before
|
from lemur.certificates.models import get_cn, get_not_after, get_not_before
|
||||||
|
|
||||||
|
|
||||||
class Authority(db.Model):
|
class Authority(db.Model):
|
||||||
|
@ -44,9 +44,9 @@ class Authority(db.Model):
|
||||||
self.owner = owner
|
self.owner = owner
|
||||||
self.plugin_name = plugin_name
|
self.plugin_name = plugin_name
|
||||||
cert = x509.load_pem_x509_certificate(str(body), default_backend())
|
cert = x509.load_pem_x509_certificate(str(body), default_backend())
|
||||||
self.cn = cert_get_cn(cert)
|
self.cn = get_cn(cert)
|
||||||
self.not_before = cert_get_not_before(cert)
|
self.not_before = get_not_before(cert)
|
||||||
self.not_after = cert_get_not_after(cert)
|
self.not_after = get_not_after(cert)
|
||||||
self.roles = roles
|
self.roles = roles
|
||||||
self.description = description
|
self.description = description
|
||||||
|
|
||||||
|
|
|
@ -63,7 +63,11 @@ def create_name(issuer, not_before, not_after, subject, san):
|
||||||
return temp.replace(" ", "-")
|
return temp.replace(" ", "-")
|
||||||
|
|
||||||
|
|
||||||
def cert_get_cn(cert):
|
def get_signing_algorithm(cert):
|
||||||
|
return cert.signature_hash_algorithm.name
|
||||||
|
|
||||||
|
|
||||||
|
def get_cn(cert):
|
||||||
"""
|
"""
|
||||||
Attempts to get a sane common name from a given certificate.
|
Attempts to get a sane common name from a given certificate.
|
||||||
|
|
||||||
|
@ -75,7 +79,7 @@ def cert_get_cn(cert):
|
||||||
)[0].value.strip()
|
)[0].value.strip()
|
||||||
|
|
||||||
|
|
||||||
def cert_get_domains(cert):
|
def get_domains(cert):
|
||||||
"""
|
"""
|
||||||
Attempts to get an domains listed in a certificate.
|
Attempts to get an domains listed in a certificate.
|
||||||
If 'subjectAltName' extension is not available we simply
|
If 'subjectAltName' extension is not available we simply
|
||||||
|
@ -96,7 +100,7 @@ def cert_get_domains(cert):
|
||||||
return domains
|
return domains
|
||||||
|
|
||||||
|
|
||||||
def cert_get_serial(cert):
|
def get_serial(cert):
|
||||||
"""
|
"""
|
||||||
Fetch the serial number from the certificate.
|
Fetch the serial number from the certificate.
|
||||||
|
|
||||||
|
@ -106,7 +110,7 @@ def cert_get_serial(cert):
|
||||||
return cert.serial
|
return cert.serial
|
||||||
|
|
||||||
|
|
||||||
def cert_is_san(cert):
|
def is_san(cert):
|
||||||
"""
|
"""
|
||||||
Determines if a given certificate is a SAN certificate.
|
Determines if a given certificate is a SAN certificate.
|
||||||
SAN certificates are simply certificates that cover multiple domains.
|
SAN certificates are simply certificates that cover multiple domains.
|
||||||
|
@ -114,18 +118,18 @@ def cert_is_san(cert):
|
||||||
:param cert:
|
:param cert:
|
||||||
:return: Bool
|
:return: Bool
|
||||||
"""
|
"""
|
||||||
if len(cert_get_domains(cert)) > 1:
|
if len(get_domains(cert)) > 1:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def cert_is_wildcard(cert):
|
def is_wildcard(cert):
|
||||||
"""
|
"""
|
||||||
Determines if certificate is a wildcard certificate.
|
Determines if certificate is a wildcard certificate.
|
||||||
|
|
||||||
:param cert:
|
:param cert:
|
||||||
:return: Bool
|
:return: Bool
|
||||||
"""
|
"""
|
||||||
domains = cert_get_domains(cert)
|
domains = get_domains(cert)
|
||||||
if len(domains) == 1 and domains[0][0:1] == "*":
|
if len(domains) == 1 and domains[0][0:1] == "*":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -133,7 +137,7 @@ def cert_is_wildcard(cert):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def cert_get_bitstrength(cert):
|
def get_bitstrength(cert):
|
||||||
"""
|
"""
|
||||||
Calculates a certificates public key bit length.
|
Calculates a certificates public key bit length.
|
||||||
|
|
||||||
|
@ -143,7 +147,7 @@ def cert_get_bitstrength(cert):
|
||||||
return cert.public_key().key_size
|
return cert.public_key().key_size
|
||||||
|
|
||||||
|
|
||||||
def cert_get_issuer(cert):
|
def get_issuer(cert):
|
||||||
"""
|
"""
|
||||||
Gets a sane issuer from a given certificate.
|
Gets a sane issuer from a given certificate.
|
||||||
|
|
||||||
|
@ -160,7 +164,7 @@ def cert_get_issuer(cert):
|
||||||
current_app.logger.error("Unable to get issuer! {0}".format(e))
|
current_app.logger.error("Unable to get issuer! {0}".format(e))
|
||||||
|
|
||||||
|
|
||||||
def cert_get_not_before(cert):
|
def get_not_before(cert):
|
||||||
"""
|
"""
|
||||||
Gets the naive datetime of the certificates 'not_before' field.
|
Gets the naive datetime of the certificates 'not_before' field.
|
||||||
This field denotes the first date in time which the given certificate
|
This field denotes the first date in time which the given certificate
|
||||||
|
@ -172,7 +176,7 @@ def cert_get_not_before(cert):
|
||||||
return cert.not_valid_before
|
return cert.not_valid_before
|
||||||
|
|
||||||
|
|
||||||
def cert_get_not_after(cert):
|
def get_not_after(cert):
|
||||||
"""
|
"""
|
||||||
Gets the naive datetime of the certificates 'not_after' field.
|
Gets the naive datetime of the certificates 'not_after' field.
|
||||||
This field denotes the last date in time which the given certificate
|
This field denotes the last date in time which the given certificate
|
||||||
|
@ -224,6 +228,7 @@ class Certificate(db.Model):
|
||||||
not_before = Column(DateTime)
|
not_before = Column(DateTime)
|
||||||
not_after = Column(DateTime)
|
not_after = Column(DateTime)
|
||||||
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
|
date_created = Column(DateTime, PassiveDefault(func.now()), nullable=False)
|
||||||
|
signing_algorithm = Column(String(128))
|
||||||
user_id = Column(Integer, ForeignKey('users.id'))
|
user_id = Column(Integer, ForeignKey('users.id'))
|
||||||
authority_id = Column(Integer, ForeignKey('authorities.id'))
|
authority_id = Column(Integer, ForeignKey('authorities.id'))
|
||||||
notifications = relationship("Notification", secondary=certificate_notification_associations, backref='certificate')
|
notifications = relationship("Notification", secondary=certificate_notification_associations, backref='certificate')
|
||||||
|
@ -237,16 +242,17 @@ class Certificate(db.Model):
|
||||||
self.private_key = private_key
|
self.private_key = private_key
|
||||||
self.chain = chain
|
self.chain = chain
|
||||||
cert = x509.load_pem_x509_certificate(str(self.body), default_backend())
|
cert = x509.load_pem_x509_certificate(str(self.body), default_backend())
|
||||||
self.bits = cert_get_bitstrength(cert)
|
self.signing_algorithm = get_signing_algorithm(cert)
|
||||||
self.issuer = cert_get_issuer(cert)
|
self.bits = get_bitstrength(cert)
|
||||||
self.serial = cert_get_serial(cert)
|
self.issuer = get_issuer(cert)
|
||||||
self.cn = cert_get_cn(cert)
|
self.serial = get_serial(cert)
|
||||||
self.san = cert_is_san(cert)
|
self.cn = get_cn(cert)
|
||||||
self.not_before = cert_get_not_before(cert)
|
self.san = is_san(cert)
|
||||||
self.not_after = cert_get_not_after(cert)
|
self.not_before = get_not_before(cert)
|
||||||
|
self.not_after = get_not_after(cert)
|
||||||
self.name = create_name(self.issuer, self.not_before, self.not_after, self.cn, self.san)
|
self.name = create_name(self.issuer, self.not_before, self.not_after, self.cn, self.san)
|
||||||
|
|
||||||
for domain in cert_get_domains(cert):
|
for domain in get_domains(cert):
|
||||||
self.domains.append(Domain(name=domain))
|
self.domains.append(Domain(name=domain))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -46,6 +46,7 @@ FIELDS = {
|
||||||
'notBefore': fields.DateTime(dt_format='iso8601', attribute='not_before'),
|
'notBefore': fields.DateTime(dt_format='iso8601', attribute='not_before'),
|
||||||
'notAfter': fields.DateTime(dt_format='iso8601', attribute='not_after'),
|
'notAfter': fields.DateTime(dt_format='iso8601', attribute='not_after'),
|
||||||
'cn': fields.String,
|
'cn': fields.String,
|
||||||
|
'signingAlgorithm': fields.String(attribute='signing_algorithm'),
|
||||||
'status': fields.String,
|
'status': fields.String,
|
||||||
'body': fields.String
|
'body': fields.String
|
||||||
}
|
}
|
||||||
|
@ -400,6 +401,7 @@ class CertificatesUpload(AuthenticatedResource):
|
||||||
"active": true,
|
"active": true,
|
||||||
"notBefore": "2015-06-05T17:09:39",
|
"notBefore": "2015-06-05T17:09:39",
|
||||||
"notAfter": "2015-06-10T17:09:39",
|
"notAfter": "2015-06-10T17:09:39",
|
||||||
|
"signingAlgorithm": "sha2"
|
||||||
"cn": "example.com",
|
"cn": "example.com",
|
||||||
"status": "unknown"
|
"status": "unknown"
|
||||||
}
|
}
|
||||||
|
@ -543,6 +545,7 @@ class Certificates(AuthenticatedResource):
|
||||||
"active": true,
|
"active": true,
|
||||||
"notBefore": "2015-06-05T17:09:39",
|
"notBefore": "2015-06-05T17:09:39",
|
||||||
"notAfter": "2015-06-10T17:09:39",
|
"notAfter": "2015-06-10T17:09:39",
|
||||||
|
"signingAlgorithm": "sha2",
|
||||||
"cn": "example.com",
|
"cn": "example.com",
|
||||||
"status": "unknown"
|
"status": "unknown"
|
||||||
}
|
}
|
||||||
|
@ -677,6 +680,7 @@ class NotificationCertificatesList(AuthenticatedResource):
|
||||||
"active": true,
|
"active": true,
|
||||||
"notBefore": "2015-06-05T17:09:39",
|
"notBefore": "2015-06-05T17:09:39",
|
||||||
"notAfter": "2015-06-10T17:09:39",
|
"notAfter": "2015-06-10T17:09:39",
|
||||||
|
"signingAlgorithm": "sha2",
|
||||||
"cn": "example.com",
|
"cn": "example.com",
|
||||||
"status": "unknown"
|
"status": "unknown"
|
||||||
}
|
}
|
||||||
|
|
|
@ -716,6 +716,24 @@ def publish_verisign_units():
|
||||||
requests.post('http://localhost:8078/metrics', data=json.dumps(metric))
|
requests.post('http://localhost:8078/metrics', data=json.dumps(metric))
|
||||||
|
|
||||||
|
|
||||||
|
@manager.command
|
||||||
|
def backfill_signing_algo():
|
||||||
|
"""
|
||||||
|
Will attempt to backfill the signing_algorithm column
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from lemur.certificates.models import get_signing_algorithm
|
||||||
|
for c in cert_service.get_all_certs():
|
||||||
|
cert = x509.load_pem_x509_certificate(str(c.body), default_backend())
|
||||||
|
c.signing_algorithm = get_signing_algorithm(cert)
|
||||||
|
c.signing_algorithm
|
||||||
|
database.update(c)
|
||||||
|
print(c.signing_algorithm)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
manager.add_command("start", LemurServer())
|
manager.add_command("start", LemurServer())
|
||||||
manager.add_command("runserver", Server(host='127.0.0.1'))
|
manager.add_command("runserver", Server(host='127.0.0.1'))
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Adding certificate signing algorithm
|
||||||
|
|
||||||
|
Revision ID: 4bcfa2c36623
|
||||||
|
Revises: 1ff763f5b80b
|
||||||
|
Create Date: 2015-10-06 10:03:47.993204
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '4bcfa2c36623'
|
||||||
|
down_revision = '1ff763f5b80b'
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('certificates', sa.Column('signing_algorithm', sa.String(length=128), nullable=True))
|
||||||
|
### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('certificates', 'signing_algorithm')
|
||||||
|
### end Alembic commands ###
|
|
@ -89,6 +89,10 @@
|
||||||
<strong>Bits</strong>
|
<strong>Bits</strong>
|
||||||
<span class="pull-right">{{ certificate.bits }}</span>
|
<span class="pull-right">{{ certificate.bits }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<strong>Signing Algorithm</strong>
|
||||||
|
<span class="pull-right">{{ certificate.signingAlgorithm }}</span>
|
||||||
|
</li>
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<strong>Serial</strong>
|
<strong>Serial</strong>
|
||||||
<span class="pull-right">{{ certificate.serial }}</span>
|
<span class="pull-right">{{ certificate.serial }}</span>
|
||||||
|
|
|
@ -79,6 +79,11 @@ angular.module('lemur')
|
||||||
$scope.bits = data.items;
|
$scope.bits = data.items;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
LemurRestangular.all('certificates').customGET('stats', {metric: 'signing_algorithm'})
|
||||||
|
.then(function (data) {
|
||||||
|
$scope.algos = data.items;
|
||||||
|
});
|
||||||
|
|
||||||
LemurRestangular.all('certificates').customGET('stats', {metric: 'not_after'})
|
LemurRestangular.all('certificates').customGET('stats', {metric: 'not_after'})
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
$scope.expiring = {labels: data.items.labels, values: [data.items.values]};
|
$scope.expiring = {labels: data.items.labels, values: [data.items.values]};
|
||||||
|
|
|
@ -23,8 +23,7 @@
|
||||||
<h3 class="panel-title">Issuers</h3>
|
<h3 class="panel-title">Issuers</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<canvas id="issuersPie" class="chart chart-pie" data="issuers.values" labels="issuers.labels" colours="colours"
|
<canvas id="issuersPie" class="chart chart-pie" data="issuers.values" labels="issuers.labels" colours="colours"></canvas>
|
||||||
legend="true"></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,8 +33,7 @@
|
||||||
<h3 class="panel-title">Bit Strength</h3>
|
<h3 class="panel-title">Bit Strength</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<canvas id="bitsPie" class="chart chart-pie" data="bits.values" labels="bits.labels" colours="colours"
|
<canvas id="bitsPie" class="chart chart-pie" data="bits.values" labels="bits.labels" colours="colours"></canvas>
|
||||||
legend="true"></canvas>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -47,7 +45,18 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<canvas id="destinationPie" class="chart chart-pie" data="destinations.values" labels="destinations.labels"
|
<canvas id="destinationPie" class="chart chart-pie" data="destinations.values" labels="destinations.labels"
|
||||||
colours="colours" legend="true"></canvas>
|
colours="colours"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">Signing Algorithms</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<canvas id="signingPie" class="chart chart-pie" data="algos.values" labels="algos.labels"
|
||||||
|
colours="colours"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -41,44 +41,44 @@ def test_create_basic_csr():
|
||||||
|
|
||||||
def test_cert_get_cn():
|
def test_cert_get_cn():
|
||||||
from lemur.tests.certs import INTERNAL_VALID_LONG_CERT
|
from lemur.tests.certs import INTERNAL_VALID_LONG_CERT
|
||||||
from lemur.certificates.models import cert_get_cn
|
from lemur.certificates.models import get_cn
|
||||||
|
|
||||||
assert cert_get_cn(INTERNAL_VALID_LONG_CERT) == 'long.lived.com'
|
assert get_cn(INTERNAL_VALID_LONG_CERT) == 'long.lived.com'
|
||||||
|
|
||||||
|
|
||||||
def test_cert_get_subAltDomains():
|
def test_cert_get_subAltDomains():
|
||||||
from lemur.tests.certs import INTERNAL_VALID_SAN_CERT, INTERNAL_VALID_LONG_CERT
|
from lemur.tests.certs import INTERNAL_VALID_SAN_CERT, INTERNAL_VALID_LONG_CERT
|
||||||
from lemur.certificates.models import cert_get_domains
|
from lemur.certificates.models import get_domains
|
||||||
|
|
||||||
assert cert_get_domains(INTERNAL_VALID_LONG_CERT) == []
|
assert get_domains(INTERNAL_VALID_LONG_CERT) == []
|
||||||
assert cert_get_domains(INTERNAL_VALID_SAN_CERT) == ['example2.long.com', 'example3.long.com']
|
assert get_domains(INTERNAL_VALID_SAN_CERT) == ['example2.long.com', 'example3.long.com']
|
||||||
|
|
||||||
|
|
||||||
def test_cert_is_san():
|
def test_cert_is_san():
|
||||||
from lemur.tests.certs import INTERNAL_VALID_SAN_CERT, INTERNAL_VALID_LONG_CERT
|
from lemur.tests.certs import INTERNAL_VALID_SAN_CERT, INTERNAL_VALID_LONG_CERT
|
||||||
from lemur.certificates.models import cert_is_san
|
from lemur.certificates.models import is_san
|
||||||
|
|
||||||
assert cert_is_san(INTERNAL_VALID_LONG_CERT) == None # noqa
|
assert is_san(INTERNAL_VALID_LONG_CERT) == None # noqa
|
||||||
assert cert_is_san(INTERNAL_VALID_SAN_CERT) == True # noqa
|
assert is_san(INTERNAL_VALID_SAN_CERT) == True # noqa
|
||||||
|
|
||||||
|
|
||||||
def test_cert_is_wildcard():
|
def test_cert_is_wildcard():
|
||||||
from lemur.tests.certs import INTERNAL_VALID_WILDCARD_CERT, INTERNAL_VALID_LONG_CERT
|
from lemur.tests.certs import INTERNAL_VALID_WILDCARD_CERT, INTERNAL_VALID_LONG_CERT
|
||||||
from lemur.certificates.models import cert_is_wildcard
|
from lemur.certificates.models import is_wildcard
|
||||||
assert cert_is_wildcard(INTERNAL_VALID_WILDCARD_CERT) == True # noqa
|
assert is_wildcard(INTERNAL_VALID_WILDCARD_CERT) == True # noqa
|
||||||
assert cert_is_wildcard(INTERNAL_VALID_LONG_CERT) == None # noqa
|
assert is_wildcard(INTERNAL_VALID_LONG_CERT) == None # noqa
|
||||||
|
|
||||||
|
|
||||||
def test_cert_get_bitstrength():
|
def test_cert_get_bitstrength():
|
||||||
from lemur.tests.certs import INTERNAL_VALID_LONG_CERT
|
from lemur.tests.certs import INTERNAL_VALID_LONG_CERT
|
||||||
from lemur.certificates.models import cert_get_bitstrength
|
from lemur.certificates.models import get_bitstrength
|
||||||
assert cert_get_bitstrength(INTERNAL_VALID_LONG_CERT) == 2048
|
assert get_bitstrength(INTERNAL_VALID_LONG_CERT) == 2048
|
||||||
|
|
||||||
|
|
||||||
def test_cert_get_issuer():
|
def test_cert_get_issuer():
|
||||||
from lemur.tests.certs import INTERNAL_VALID_LONG_CERT
|
from lemur.tests.certs import INTERNAL_VALID_LONG_CERT
|
||||||
from lemur.certificates.models import cert_get_issuer
|
from lemur.certificates.models import get_issuer
|
||||||
assert cert_get_issuer(INTERNAL_VALID_LONG_CERT) == 'Example'
|
assert get_issuer(INTERNAL_VALID_LONG_CERT) == 'Example'
|
||||||
|
|
||||||
|
|
||||||
def test_get_name_from_arn():
|
def test_get_name_from_arn():
|
||||||
|
|
Loading…
Reference in New Issue