Merge branch 'master' into key_type_column
This commit is contained in:
commit
477f2fa1c2
|
@ -653,13 +653,20 @@ Active Directory Certificate Services Plugin
|
||||||
:noindex:
|
:noindex:
|
||||||
|
|
||||||
Template to be used for certificate issuing. Usually display name w/o spaces
|
Template to be used for certificate issuing. Usually display name w/o spaces
|
||||||
|
|
||||||
|
.. data:: ADCS_TEMPLATE_<upper(authority.name)>
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
If there is a config variable ADCS_TEMPLATE_<upper(authority.name)> take the value as Cert template else default to ADCS_TEMPLATE to be compatible with former versions. Template to be used for certificate issuing. Usually display name w/o spaces
|
||||||
|
|
||||||
.. data:: ADCS_START
|
.. data:: ADCS_START
|
||||||
:noindex:
|
:noindex:
|
||||||
|
Used in ADCS-Sourceplugin. Minimum id of the first certificate to be returned. ID is increased by one until ADCS_STOP. Missing cert-IDs are ignored
|
||||||
|
|
||||||
.. data:: ADCS_STOP
|
.. data:: ADCS_STOP
|
||||||
:noindex:
|
:noindex:
|
||||||
|
Used for ADCS-Sourceplugin. Maximum id of the certificates returned.
|
||||||
|
|
||||||
|
|
||||||
.. data:: ADCS_ISSUING
|
.. data:: ADCS_ISSUING
|
||||||
:noindex:
|
:noindex:
|
||||||
|
@ -672,6 +679,68 @@ Active Directory Certificate Services Plugin
|
||||||
|
|
||||||
Contains the root cert of the CA
|
Contains the root cert of the CA
|
||||||
|
|
||||||
|
Entrust Plugin
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Enables the creation of Entrust certificates. You need to set the API access up with Entrust support. Check the information in the Entrust Portal as well.
|
||||||
|
Certificates are created as "SERVER_AND_CLIENT_AUTH".
|
||||||
|
Caution: Sometimes the entrust API does not respond in a timely manner. This error is handled and reported by the plugin. Should this happen you just have to hit the create button again after to create a valid certificate.
|
||||||
|
The following parameters have to be set in the configuration files.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_URL
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
This is the url for the Entrust API. Refer to the API documentation.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_API_CERT
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Path to the certificate file in PEM format. This certificate is created in the onboarding process. Refer to the API documentation.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_API_KEY
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Path to the key file in RSA format. This certificate is created in the onboarding process. Refer to the API documentation. Caution: the request library cannot handle encrypted keys. The keyfile therefore has to contain the unencrypted key. Please put this in a secure location on the server.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_API_USER
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
String with the API user. This user is created in the onboarding process. Refer to the API documentation.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_API_PASS
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
String with the password for the API user. This password is created in the onboarding process. Refer to the API documentation.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_NAME
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
String with the name that should appear as certificate owner in the Entrust portal. Refer to the API documentation.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_EMAIL
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
String with the email address that should appear as certificate contact email in the Entrust portal. Refer to the API documentation.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_PHONE
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
String with the phone number that should appear as certificate contact in the Entrust portal. Refer to the API documentation.
|
||||||
|
|
||||||
|
.. data:: ENTRUST_ISSUING
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Contains the issuing cert of the CA
|
||||||
|
|
||||||
|
.. data:: ENTRUST_ROOT
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
Contains the root cert of the CA
|
||||||
|
|
||||||
|
.. data:: ENTRUST_PRODUCT_<upper(authority.name)>
|
||||||
|
:noindex:
|
||||||
|
|
||||||
|
If there is a config variable ENTRUST_PRODUCT_<upper(authority.name)> take the value as cert product name else default to "STANDARD_SSL". Refer to the API documentation for valid products names.
|
||||||
|
|
||||||
Verisign Issuer Plugin
|
Verisign Issuer Plugin
|
||||||
~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Set the version information."""
|
||||||
|
try:
|
||||||
|
VERSION = __import__("pkg_resources").get_distribution(__name__).version
|
||||||
|
except Exception as e:
|
||||||
|
VERSION = "unknown"
|
|
@ -0,0 +1,228 @@
|
||||||
|
from lemur.plugins.bases import IssuerPlugin, SourcePlugin
|
||||||
|
import arrow
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from lemur.plugins import lemur_entrust as ENTRUST
|
||||||
|
from flask import current_app
|
||||||
|
from lemur.extensions import metrics
|
||||||
|
from lemur.common.utils import validate_conf
|
||||||
|
|
||||||
|
|
||||||
|
def log_status_code(r, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Is a request hook that logs all status codes to the ENTRUST api.
|
||||||
|
|
||||||
|
:param r:
|
||||||
|
:param args:
|
||||||
|
:param kwargs:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
metrics.send("ENTRUST_status_code_{}".format(r.status_code), "counter", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def determine_end_date(end_date):
|
||||||
|
"""
|
||||||
|
Determine appropriate end date
|
||||||
|
:param end_date:
|
||||||
|
:return: validity_end
|
||||||
|
"""
|
||||||
|
# ENTRUST only allows 13 months of max certificate duration
|
||||||
|
max_validity_end = arrow.utcnow().shift(years=1, months=+1).format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
if not end_date:
|
||||||
|
end_date = max_validity_end
|
||||||
|
|
||||||
|
if end_date > max_validity_end:
|
||||||
|
end_date = max_validity_end
|
||||||
|
return end_date
|
||||||
|
|
||||||
|
|
||||||
|
def process_options(options):
|
||||||
|
"""
|
||||||
|
Processes and maps the incoming issuer options to fields/options that
|
||||||
|
Entrust understands
|
||||||
|
|
||||||
|
:param options:
|
||||||
|
:return: dict of valid entrust options
|
||||||
|
"""
|
||||||
|
# if there is a config variable ENTRUST_PRODUCT_<upper(authority.name)>
|
||||||
|
# take the value as Cert product-type
|
||||||
|
# else default to "STANDARD_SSL"
|
||||||
|
authority = options.get("authority").name.upper()
|
||||||
|
product_type = current_app.config.get("ENTRUST_PRODUCT_{0}".format(authority), "STANDARD_SSL")
|
||||||
|
|
||||||
|
if options.get("validity_end"):
|
||||||
|
validity_end = determine_end_date(options.get("validity_end"))
|
||||||
|
else:
|
||||||
|
validity_end = determine_end_date(False)
|
||||||
|
|
||||||
|
tracking_data = {
|
||||||
|
"requesterName": current_app.config.get("ENTRUST_NAME"),
|
||||||
|
"requesterEmail": current_app.config.get("ENTRUST_EMAIL"),
|
||||||
|
"requesterPhone": current_app.config.get("ENTRUST_PHONE")
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"signingAlg": "SHA-2",
|
||||||
|
"eku": "SERVER_AND_CLIENT_AUTH",
|
||||||
|
"certType": product_type,
|
||||||
|
"certExpiryDate": validity_end,
|
||||||
|
"tracking": tracking_data
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def handle_response(my_response):
|
||||||
|
"""
|
||||||
|
Helper function for parsing responses from the Entrust API.
|
||||||
|
:param content:
|
||||||
|
:return: :raise Exception:
|
||||||
|
"""
|
||||||
|
msg = {
|
||||||
|
200: "The request had the validateOnly flag set to true and validation was successful.",
|
||||||
|
201: "Certificate created",
|
||||||
|
202: "Request accepted and queued for approval",
|
||||||
|
400: "Invalid request parameters",
|
||||||
|
404: "Unknown jobId",
|
||||||
|
429: "Too many requests"
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
d = json.loads(my_response.content)
|
||||||
|
except Exception as e:
|
||||||
|
# catch an empty jason object here
|
||||||
|
d = {'errors': 'No detailled message'}
|
||||||
|
s = my_response.status_code
|
||||||
|
if s > 399:
|
||||||
|
raise Exception("ENTRUST error: {0}\n{1}".format(msg.get(s, s), d['errors']))
|
||||||
|
current_app.logger.info("Response: {0}, {1} ".format(s, d))
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class EntrustIssuerPlugin(IssuerPlugin):
|
||||||
|
title = "ENTRUST"
|
||||||
|
slug = "entrust-issuer"
|
||||||
|
description = "Enables the creation of certificates by ENTRUST"
|
||||||
|
version = ENTRUST.VERSION
|
||||||
|
|
||||||
|
author = "sirferl"
|
||||||
|
author_url = "https://github.com/sirferl/lemur"
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize the issuer with the appropriate details."""
|
||||||
|
required_vars = [
|
||||||
|
"ENTRUST_API_CERT",
|
||||||
|
"ENTRUST_API_KEY",
|
||||||
|
"ENTRUST_API_USER",
|
||||||
|
"ENTRUST_API_PASS",
|
||||||
|
"ENTRUST_URL",
|
||||||
|
"ENTRUST_ROOT",
|
||||||
|
"ENTRUST_NAME",
|
||||||
|
"ENTRUST_EMAIL",
|
||||||
|
"ENTRUST_PHONE",
|
||||||
|
"ENTRUST_ISSUING",
|
||||||
|
]
|
||||||
|
validate_conf(current_app, required_vars)
|
||||||
|
|
||||||
|
self.session = requests.Session()
|
||||||
|
cert_file = current_app.config.get("ENTRUST_API_CERT")
|
||||||
|
key_file = current_app.config.get("ENTRUST_API_KEY")
|
||||||
|
user = current_app.config.get("ENTRUST_API_USER")
|
||||||
|
password = current_app.config.get("ENTRUST_API_PASS")
|
||||||
|
self.session.cert = (cert_file, key_file)
|
||||||
|
self.session.auth = (user, password)
|
||||||
|
self.session.hooks = dict(response=log_status_code)
|
||||||
|
# self.session.config['keep_alive'] = False
|
||||||
|
super(EntrustIssuerPlugin, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def create_certificate(self, csr, issuer_options):
|
||||||
|
"""
|
||||||
|
Creates an Entrust certificate.
|
||||||
|
|
||||||
|
:param csr:
|
||||||
|
:param issuer_options:
|
||||||
|
:return: :raise Exception:
|
||||||
|
"""
|
||||||
|
current_app.logger.info(
|
||||||
|
"Requesting options: {0}".format(issuer_options)
|
||||||
|
)
|
||||||
|
|
||||||
|
url = current_app.config.get("ENTRUST_URL") + "/certificates"
|
||||||
|
|
||||||
|
data = process_options(issuer_options)
|
||||||
|
data["csr"] = csr
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.post(url, json=data, timeout=(15, 40))
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise Exception("Timeout for POST")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception("Error for POST {0}".format(e))
|
||||||
|
|
||||||
|
response_dict = handle_response(response)
|
||||||
|
external_id = response_dict['trackingId']
|
||||||
|
cert = response_dict['endEntityCert']
|
||||||
|
chain = response_dict['chainCerts'][1]
|
||||||
|
current_app.logger.info(
|
||||||
|
"Received Chain: {0}".format(chain)
|
||||||
|
)
|
||||||
|
|
||||||
|
return cert, chain, external_id
|
||||||
|
|
||||||
|
def revoke_certificate(self, certificate, comments):
|
||||||
|
"""Revoke a Digicert certificate."""
|
||||||
|
base_url = current_app.config.get("ENTRUST_URL")
|
||||||
|
|
||||||
|
# make certificate revoke request
|
||||||
|
revoke_url = "{0}/certificates/{1}/revocations".format(
|
||||||
|
base_url, certificate.external_id
|
||||||
|
)
|
||||||
|
metrics.send("entrust_revoke_certificate", "counter", 1)
|
||||||
|
if comments == '' or not comments:
|
||||||
|
comments = "revoked via API"
|
||||||
|
data = {
|
||||||
|
"crlReason": "superseded",
|
||||||
|
"revocationComment": comments
|
||||||
|
}
|
||||||
|
response = self.session.post(revoke_url, json=data)
|
||||||
|
|
||||||
|
data = handle_response(response)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_authority(options):
|
||||||
|
"""Create an authority.
|
||||||
|
Creates an authority, this authority is then used by Lemur to
|
||||||
|
allow a user to specify which Certificate Authority they want
|
||||||
|
to sign their certificate.
|
||||||
|
|
||||||
|
:param options:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
entrust_root = current_app.config.get("ENTRUST_ROOT")
|
||||||
|
entrust_issuing = current_app.config.get("ENTRUST_ISSUING")
|
||||||
|
role = {"username": "", "password": "", "name": "entrust"}
|
||||||
|
current_app.logger.info("Creating Auth: {0} {1}".format(options, entrust_issuing))
|
||||||
|
return entrust_root, "", [role]
|
||||||
|
|
||||||
|
def get_ordered_certificate(self, order_id):
|
||||||
|
raise NotImplementedError("Not implemented\n", self, order_id)
|
||||||
|
|
||||||
|
def canceled_ordered_certificate(self, pending_cert, **kwargs):
|
||||||
|
raise NotImplementedError("Not implemented\n", self, pending_cert, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class EntrustSourcePlugin(SourcePlugin):
|
||||||
|
title = "ENTRUST"
|
||||||
|
slug = "entrust-source"
|
||||||
|
description = "Enables the collecion of certificates"
|
||||||
|
version = ENTRUST.VERSION
|
||||||
|
|
||||||
|
author = "sirferl"
|
||||||
|
author_url = "https://github.com/sirferl/lemur"
|
||||||
|
|
||||||
|
def get_certificates(self, options, **kwargs):
|
||||||
|
# Not needed for ENTRUST
|
||||||
|
raise NotImplementedError("Not implemented\n", self, options, **kwargs)
|
||||||
|
|
||||||
|
def get_endpoints(self, options, **kwargs):
|
||||||
|
# There are no endpoints in ENTRUST
|
||||||
|
raise NotImplementedError("Not implemented\n", self, options, **kwargs)
|
|
@ -4,7 +4,7 @@
|
||||||
#
|
#
|
||||||
# pip-compile --no-index --output-file=requirements-docs.txt requirements-docs.in
|
# pip-compile --no-index --output-file=requirements-docs.txt requirements-docs.in
|
||||||
#
|
#
|
||||||
acme==1.7.0 # via -r requirements.txt
|
acme==1.8.0 # via -r requirements.txt
|
||||||
alabaster==0.7.12 # via sphinx
|
alabaster==0.7.12 # via sphinx
|
||||||
alembic-autogenerate-enums==0.0.2 # via -r requirements.txt
|
alembic-autogenerate-enums==0.0.2 # via -r requirements.txt
|
||||||
alembic==1.4.2 # via -r requirements.txt, flask-migrate
|
alembic==1.4.2 # via -r requirements.txt, flask-migrate
|
||||||
|
|
|
@ -18,14 +18,14 @@ cffi==1.14.0 # via cryptography
|
||||||
cfn-lint==0.29.5 # via moto
|
cfn-lint==0.29.5 # via moto
|
||||||
chardet==3.0.4 # via requests
|
chardet==3.0.4 # via requests
|
||||||
click==7.1.2 # via black, flask
|
click==7.1.2 # via black, flask
|
||||||
coverage==5.2.1 # via -r requirements-tests.in
|
coverage==5.3 # via -r requirements-tests.in
|
||||||
cryptography==3.1 # via moto, sshpubkeys
|
cryptography==3.1 # via moto, python-jose, sshpubkeys
|
||||||
decorator==4.4.2 # via networkx
|
decorator==4.4.2 # via networkx
|
||||||
docker==4.2.0 # via moto
|
docker==4.2.0 # via moto
|
||||||
docutils==0.15.2 # via botocore
|
docutils==0.15.2 # via botocore
|
||||||
ecdsa==0.15 # via python-jose, sshpubkeys
|
ecdsa==0.14.1 # via moto, python-jose, sshpubkeys
|
||||||
factory-boy==3.0.1 # via -r requirements-tests.in
|
factory-boy==3.0.1 # via -r requirements-tests.in
|
||||||
faker==4.1.2 # via -r requirements-tests.in, factory-boy
|
faker==4.1.3 # via -r requirements-tests.in, factory-boy
|
||||||
fakeredis==1.4.3 # via -r requirements-tests.in
|
fakeredis==1.4.3 # via -r requirements-tests.in
|
||||||
flask==1.1.2 # via pytest-flask
|
flask==1.1.2 # via pytest-flask
|
||||||
freezegun==1.0.0 # via -r requirements-tests.in
|
freezegun==1.0.0 # via -r requirements-tests.in
|
||||||
|
@ -43,10 +43,10 @@ jsonpatch==1.25 # via cfn-lint
|
||||||
jsonpickle==1.4 # via aws-xray-sdk
|
jsonpickle==1.4 # via aws-xray-sdk
|
||||||
jsonpointer==2.0 # via jsonpatch
|
jsonpointer==2.0 # via jsonpatch
|
||||||
jsonschema==3.2.0 # via aws-sam-translator, cfn-lint
|
jsonschema==3.2.0 # via aws-sam-translator, cfn-lint
|
||||||
markupsafe==1.1.1 # via jinja2
|
markupsafe==1.1.1 # via jinja2, moto
|
||||||
mock==4.0.2 # via moto
|
mock==4.0.2 # via moto
|
||||||
more-itertools==8.2.0 # via pytest
|
more-itertools==8.2.0 # via moto, pytest
|
||||||
moto==1.3.14 # via -r requirements-tests.in
|
moto==1.3.16 # via -r requirements-tests.in
|
||||||
mypy-extensions==0.4.3 # via black
|
mypy-extensions==0.4.3 # via black
|
||||||
networkx==2.4 # via cfn-lint
|
networkx==2.4 # via cfn-lint
|
||||||
nose==1.3.7 # via -r requirements-tests.in
|
nose==1.3.7 # via -r requirements-tests.in
|
||||||
|
@ -62,9 +62,9 @@ pyparsing==2.4.7 # via packaging
|
||||||
pyrsistent==0.16.0 # via jsonschema
|
pyrsistent==0.16.0 # via jsonschema
|
||||||
pytest-flask==1.0.0 # via -r requirements-tests.in
|
pytest-flask==1.0.0 # via -r requirements-tests.in
|
||||||
pytest-mock==3.3.1 # via -r requirements-tests.in
|
pytest-mock==3.3.1 # via -r requirements-tests.in
|
||||||
pytest==6.0.1 # via -r requirements-tests.in, pytest-flask, pytest-mock
|
pytest==6.0.2 # via -r requirements-tests.in, pytest-flask, pytest-mock
|
||||||
python-dateutil==2.8.1 # via botocore, faker, freezegun, moto
|
python-dateutil==2.8.1 # via botocore, faker, freezegun, moto
|
||||||
python-jose==3.1.0 # via moto
|
python-jose[cryptography]==3.1.0 # via moto
|
||||||
pytz==2019.3 # via moto
|
pytz==2019.3 # via moto
|
||||||
pyyaml==5.3.1 # via -r requirements-tests.in, bandit, cfn-lint, moto
|
pyyaml==5.3.1 # via -r requirements-tests.in, bandit, cfn-lint, moto
|
||||||
redis==3.5.3 # via fakeredis
|
redis==3.5.3 # via fakeredis
|
||||||
|
@ -88,7 +88,7 @@ websocket-client==0.57.0 # via docker
|
||||||
werkzeug==1.0.1 # via flask, moto, pytest-flask
|
werkzeug==1.0.1 # via flask, moto, pytest-flask
|
||||||
wrapt==1.12.1 # via aws-xray-sdk
|
wrapt==1.12.1 # via aws-xray-sdk
|
||||||
xmltodict==0.12.0 # via moto
|
xmltodict==0.12.0 # via moto
|
||||||
zipp==3.1.0 # via importlib-metadata
|
zipp==3.1.0 # via importlib-metadata, moto
|
||||||
|
|
||||||
# The following packages are considered to be unsafe in a requirements file:
|
# The following packages are considered to be unsafe in a requirements file:
|
||||||
# setuptools
|
# setuptools
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
#
|
#
|
||||||
# pip-compile --no-index --output-file=requirements.txt requirements.in
|
# pip-compile --no-index --output-file=requirements.txt requirements.in
|
||||||
#
|
#
|
||||||
acme==1.7.0 # via -r requirements.in
|
acme==1.8.0 # via -r requirements.in
|
||||||
alembic-autogenerate-enums==0.0.2 # via -r requirements.in
|
alembic-autogenerate-enums==0.0.2 # via -r requirements.in
|
||||||
alembic==1.4.2 # via flask-migrate
|
alembic==1.4.2 # via flask-migrate
|
||||||
amqp==2.5.2 # via kombu
|
amqp==2.5.2 # via kombu
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -153,7 +153,9 @@ setup(
|
||||||
'vault_source = lemur.plugins.lemur_vault_dest.plugin:VaultSourcePlugin',
|
'vault_source = lemur.plugins.lemur_vault_dest.plugin:VaultSourcePlugin',
|
||||||
'vault_desination = lemur.plugins.lemur_vault_dest.plugin:VaultDestinationPlugin',
|
'vault_desination = lemur.plugins.lemur_vault_dest.plugin:VaultDestinationPlugin',
|
||||||
'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin',
|
'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin',
|
||||||
'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin'
|
'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin',
|
||||||
|
'entrust_issuer = lemur.plugins.lemur_entrust.plugin:EntrustIssuerPlugin',
|
||||||
|
'entrust_source = lemur.plugins.lemur_entrust.plugin:EntrustSourcePlugin'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
classifiers=[
|
classifiers=[
|
||||||
|
|
Loading…
Reference in New Issue