mirror of
				https://github.com/laurivosandi/certidude
				synced 2025-10-31 01:19:11 +00:00 
			
		
		
		
	api: Add preliminary SCEP support
This commit is contained in:
		
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -64,3 +64,7 @@ node_modules/ | |||||||
| # Ignore autogenerated files | # Ignore autogenerated files | ||||||
| certidude/static/js/nunjucks* | certidude/static/js/nunjucks* | ||||||
| certidude/static/js/templates.js | certidude/static/js/templates.js | ||||||
|  |  | ||||||
|  | # Ignore patch | ||||||
|  | *.orig | ||||||
|  | *.rej | ||||||
|   | |||||||
| @@ -210,6 +210,11 @@ def certidude_app(log_handlers=[]): | |||||||
|     # Bootstrap resource |     # Bootstrap resource | ||||||
|     app.add_route("/api/bootstrap/", BootstrapResource()) |     app.add_route("/api/bootstrap/", BootstrapResource()) | ||||||
|  |  | ||||||
|  |     # Add SCEP handler if we have any whitelisted subnets | ||||||
|  |     if config.SCEP_SUBNETS: | ||||||
|  |         from .scep import SCEPResource | ||||||
|  |         app.add_route("/api/scep/", SCEPResource()) | ||||||
|  |  | ||||||
|     # Add sink for serving static files |     # Add sink for serving static files | ||||||
|     app.add_sink(StaticResource(os.path.join(__file__, "..", "..", "static"))) |     app.add_sink(StaticResource(os.path.join(__file__, "..", "..", "static"))) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										273
									
								
								certidude/api/scep.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										273
									
								
								certidude/api/scep.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,273 @@ | |||||||
|  | from __future__ import unicode_literals, division, absolute_import, print_function | ||||||
|  | import click | ||||||
|  | import hashlib | ||||||
|  | import os | ||||||
|  | from asn1crypto import cms, algos, x509 | ||||||
|  | from asn1crypto.core import ObjectIdentifier, SetOf, PrintableString | ||||||
|  | from base64 import b64decode, b64encode | ||||||
|  | from certbuilder import pem_armor_certificate | ||||||
|  | from certidude import authority, push, config | ||||||
|  | from certidude.firewall import whitelist_subnets | ||||||
|  | from oscrypto import keys, asymmetric, symmetric | ||||||
|  | from oscrypto.errors import SignatureError | ||||||
|  |  | ||||||
|  | # Monkey patch asn1crypto | ||||||
|  |  | ||||||
|  | class SetOfPrintableString(SetOf): | ||||||
|  |     _child_spec = PrintableString | ||||||
|  |  | ||||||
|  | cms.CMSAttributeType._map['2.16.840.1.113733.1.9.2'] = "message_type" | ||||||
|  | cms.CMSAttributeType._map['2.16.840.1.113733.1.9.3'] = "pki_status" | ||||||
|  | cms.CMSAttributeType._map['2.16.840.1.113733.1.9.4'] = "fail_info" | ||||||
|  | cms.CMSAttributeType._map['2.16.840.1.113733.1.9.5'] = "sender_nonce" | ||||||
|  | cms.CMSAttributeType._map['2.16.840.1.113733.1.9.6'] = "recipient_nonce" | ||||||
|  | cms.CMSAttributeType._map['2.16.840.1.113733.1.9.7'] = "trans_id" | ||||||
|  |  | ||||||
|  | cms.CMSAttribute._oid_specs['message_type'] = SetOfPrintableString | ||||||
|  | cms.CMSAttribute._oid_specs['pki_status'] = SetOfPrintableString | ||||||
|  | cms.CMSAttribute._oid_specs['fail_info'] = SetOfPrintableString | ||||||
|  | cms.CMSAttribute._oid_specs['sender_nonce'] = cms.SetOfOctetString | ||||||
|  | cms.CMSAttribute._oid_specs['recipient_nonce'] = cms.SetOfOctetString | ||||||
|  | cms.CMSAttribute._oid_specs['trans_id'] = SetOfPrintableString | ||||||
|  |  | ||||||
|  | class SCEPError(Exception): code = 25 # system failure | ||||||
|  | class SCEPBadAlg(SCEPError): code = 0 | ||||||
|  | class SCEPBadMessageCheck(SCEPError): code = 1 | ||||||
|  | class SCEPBadRequest(SCEPError): code = 2 | ||||||
|  | class SCEPBadTime(SCEPError): code = 3 | ||||||
|  | class SCEPBadCertId(SCEPError): code = 4 | ||||||
|  |  | ||||||
|  | class SCEPResource(object): | ||||||
|  |     @whitelist_subnets(config.SCEP_SUBNETS) | ||||||
|  |     def on_get(self, req, resp): | ||||||
|  |         operation = req.get_param("operation") | ||||||
|  |         if operation.lower() == "getcacert": | ||||||
|  |             resp.stream = keys.parse_certificate(authority.ca_buf).dump() | ||||||
|  |             resp.append_header("Content-Type", "application/x-x509-ca-cert") | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         # Parse CA certificate | ||||||
|  |         fh = open(config.AUTHORITY_CERTIFICATE_PATH) | ||||||
|  |         server_certificate = asymmetric.load_certificate(fh.read()) | ||||||
|  |         fh.close() | ||||||
|  |  | ||||||
|  |         # If we bump into exceptions later | ||||||
|  |         encrypted_container = b"" | ||||||
|  |         attr_list = [ | ||||||
|  |             cms.CMSAttribute({ | ||||||
|  |                 'type': "message_type", | ||||||
|  |                 'values': ["3"] | ||||||
|  |             }), | ||||||
|  |             cms.CMSAttribute({ | ||||||
|  |                 'type': "pki_status", | ||||||
|  |                 'values': ["2"] # rejected | ||||||
|  |             }) | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             info = cms.ContentInfo.load(b64decode(req.get_param("message", required=True))) | ||||||
|  |  | ||||||
|  |             ############################################### | ||||||
|  |             ### Verify signature of the outer container ### | ||||||
|  |             ############################################### | ||||||
|  |  | ||||||
|  |             signed_envelope = info['content'] | ||||||
|  |             encap_content_info = signed_envelope['encap_content_info'] | ||||||
|  |             encap_content = encap_content_info['content'] | ||||||
|  |  | ||||||
|  |             # TODO: try except | ||||||
|  |             current_certificate, = signed_envelope["certificates"] | ||||||
|  |             signer, = signed_envelope["signer_infos"] | ||||||
|  |  | ||||||
|  |             # TODO: compare cert to current one if we are renewing | ||||||
|  |  | ||||||
|  |             assert signer["digest_algorithm"]["algorithm"].native == "md5" | ||||||
|  |             assert signer["signature_algorithm"]["algorithm"].native == "rsassa_pkcs1v15" | ||||||
|  |             message_digest = None | ||||||
|  |             transaction_id = None | ||||||
|  |             sender_nonce = None | ||||||
|  |  | ||||||
|  |             for attr in signer["signed_attrs"]: | ||||||
|  |                 if attr["type"].native == "sender_nonce": | ||||||
|  |                     sender_nonce, = attr["values"] | ||||||
|  |                 elif attr["type"].native == "trans_id": | ||||||
|  |                     transaction_id, = attr["values"] | ||||||
|  |                 elif attr["type"].native == "message_digest": | ||||||
|  |                     message_digest, = attr["values"] | ||||||
|  |                     if hashlib.md5(encap_content.native).digest() != message_digest.native: | ||||||
|  |                         raise SCEPBadMessageCheck() | ||||||
|  |  | ||||||
|  |             assert message_digest | ||||||
|  |             msg = signer["signed_attrs"].dump(force=True) | ||||||
|  |             assert msg[0] == b"\xa0", repr(msg[0]) | ||||||
|  |  | ||||||
|  |             # Verify signature | ||||||
|  |             try: | ||||||
|  |                 asymmetric.rsa_pkcs1v15_verify( | ||||||
|  |                     asymmetric.load_certificate(current_certificate.dump()), | ||||||
|  |                     signer["signature"].native, | ||||||
|  |                     b"\x31" + msg[1:], # wtf?! | ||||||
|  |                     "md5") | ||||||
|  |             except SignatureError: | ||||||
|  |                 raise SCEPBadMessageCheck() | ||||||
|  |  | ||||||
|  |             ############################### | ||||||
|  |             ### Decrypt inner container ### | ||||||
|  |             ############################### | ||||||
|  |  | ||||||
|  |             info = cms.ContentInfo.load(encap_content.native) | ||||||
|  |             encrypted_envelope = info['content'] | ||||||
|  |             encrypted_content_info = encrypted_envelope['encrypted_content_info'] | ||||||
|  |             iv = encrypted_content_info['content_encryption_algorithm']['parameters'].native | ||||||
|  |  | ||||||
|  |             if encrypted_content_info['content_encryption_algorithm']["algorithm"].native != "des": | ||||||
|  |                 raise SCEPBadAlgo() | ||||||
|  |  | ||||||
|  |             encrypted_content = encrypted_content_info['encrypted_content'].native | ||||||
|  |             recipient, = encrypted_envelope['recipient_infos'] | ||||||
|  |  | ||||||
|  |             if recipient.native["rid"]["serial_number"] != authority.ca_cert.serial: | ||||||
|  |                 raise SCEPBadCertId() | ||||||
|  |  | ||||||
|  |             # Since CA private key is not directly readable here, we'll redirect it to signer socket | ||||||
|  |             key = b64decode(authority.signer_exec("decrypt-pkcs7", b64encode(recipient.native["encrypted_key"]))) | ||||||
|  |             if len(key) == 8: key = key * 3 # Convert DES to 3DES | ||||||
|  |             buf = symmetric.tripledes_cbc_pkcs5_decrypt(key, encrypted_content, iv) | ||||||
|  |             _, common_name = authority.store_request(buf, overwrite=True) | ||||||
|  |             cert, buf = authority.sign(common_name, overwrite=True) | ||||||
|  |             signed_certificate = asymmetric.load_certificate(buf) | ||||||
|  |             content = signed_certificate.asn1.dump() | ||||||
|  |  | ||||||
|  |         except SCEPError, e: | ||||||
|  |             attr_list.append(cms.CMSAttribute({ | ||||||
|  |                 'type': "fail_info", | ||||||
|  |                 'values': ["%d" % e.code] | ||||||
|  |             })) | ||||||
|  |         else: | ||||||
|  |  | ||||||
|  |             ################################## | ||||||
|  |             ### Degenerate inner container ### | ||||||
|  |             ################################## | ||||||
|  |  | ||||||
|  |             degenerate = cms.ContentInfo({ | ||||||
|  |                 'content_type': 'signed_data', | ||||||
|  |                 'content': cms.SignedData({ | ||||||
|  |                     'version': 'v1', | ||||||
|  |                     'certificates': [signed_certificate.asn1], | ||||||
|  |                     'digest_algorithms': [cms.DigestAlgorithm({ | ||||||
|  |                         'algorithm':'md5' | ||||||
|  |                     })], | ||||||
|  |                     'encap_content_info': { | ||||||
|  |                         'content_type': 'data', | ||||||
|  |                         'content':  cms.ContentInfo({ | ||||||
|  |                             'content_type': 'signed_data', | ||||||
|  |                             'content': None | ||||||
|  |                         }).dump() | ||||||
|  |                     }, | ||||||
|  |                     'signer_infos': [] | ||||||
|  |                 }) | ||||||
|  |             }) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             ################################ | ||||||
|  |             ### Encrypt middle container ### | ||||||
|  |             ################################ | ||||||
|  |  | ||||||
|  |             key = os.urandom(8) | ||||||
|  |             iv, encrypted_content = symmetric.des_cbc_pkcs5_encrypt(key, degenerate.dump(), os.urandom(8)) | ||||||
|  |             assert degenerate.dump() == symmetric.tripledes_cbc_pkcs5_decrypt(key*3, encrypted_content, iv) | ||||||
|  |  | ||||||
|  |             ri = cms.RecipientInfo({ | ||||||
|  |                 'ktri': cms.KeyTransRecipientInfo({ | ||||||
|  |                     'version': 'v0', | ||||||
|  |                     'rid': cms.RecipientIdentifier({ | ||||||
|  |                         'issuer_and_serial_number': cms.IssuerAndSerialNumber({ | ||||||
|  |                             'issuer': current_certificate.chosen["tbs_certificate"]["issuer"], | ||||||
|  |                             'serial_number': current_certificate.chosen["tbs_certificate"]["serial_number"], | ||||||
|  |                         }), | ||||||
|  |                     }), | ||||||
|  |                     'key_encryption_algorithm': { | ||||||
|  |                         'algorithm': 'rsa' | ||||||
|  |                     }, | ||||||
|  |                     'encrypted_key': asymmetric.rsa_pkcs1v15_encrypt( | ||||||
|  |                         asymmetric.load_certificate(current_certificate.chosen.dump()), key) | ||||||
|  |                 }) | ||||||
|  |             }) | ||||||
|  |  | ||||||
|  |             encrypted_container = cms.ContentInfo({ | ||||||
|  |                 'content_type': 'enveloped_data', | ||||||
|  |                 'content': cms.EnvelopedData({ | ||||||
|  |                     'version': 'v1', | ||||||
|  |                     'recipient_infos': [ri], | ||||||
|  |                     'encrypted_content_info': { | ||||||
|  |                         'content_type': 'data', | ||||||
|  |                         'content_encryption_algorithm': { | ||||||
|  |                             'algorithm': 'des', | ||||||
|  |                             'parameters': iv | ||||||
|  |                         }, | ||||||
|  |                         'encrypted_content': encrypted_content | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |             }).dump() | ||||||
|  |  | ||||||
|  |             attr_list = [ | ||||||
|  |                 cms.CMSAttribute({ | ||||||
|  |                     'type': 'message_digest', | ||||||
|  |                     'values': [hashlib.sha1(encrypted_container).digest()] | ||||||
|  |                 }), | ||||||
|  |                 cms.CMSAttribute({ | ||||||
|  |                     'type': "message_type", | ||||||
|  |                     'values': ["3"] | ||||||
|  |                 }), | ||||||
|  |                 cms.CMSAttribute({ | ||||||
|  |                     'type': "pki_status", | ||||||
|  |                     'values': ["0"] # ok | ||||||
|  |                 }) | ||||||
|  |             ] | ||||||
|  |         finally: | ||||||
|  |  | ||||||
|  |             ############################## | ||||||
|  |             ### Signed outer container ### | ||||||
|  |             ############################## | ||||||
|  |  | ||||||
|  |             attrs = cms.CMSAttributes(attr_list + [ | ||||||
|  |                 cms.CMSAttribute({ | ||||||
|  |                     'type': "recipient_nonce", | ||||||
|  |                     'values': [sender_nonce] | ||||||
|  |                 }), | ||||||
|  |                 cms.CMSAttribute({ | ||||||
|  |                     'type': 'trans_id', | ||||||
|  |                     'values': [transaction_id] | ||||||
|  |                 }) | ||||||
|  |             ]) | ||||||
|  |  | ||||||
|  |             signer = cms.SignerInfo({ | ||||||
|  |                 "signed_attrs": attrs, | ||||||
|  |                 'version':'v1', | ||||||
|  |                 'sid': cms.SignerIdentifier({ | ||||||
|  |                     'issuer_and_serial_number': cms.IssuerAndSerialNumber({ | ||||||
|  |                         'issuer': server_certificate.asn1["tbs_certificate"]["issuer"], | ||||||
|  |                         'serial_number': server_certificate.asn1["tbs_certificate"]["serial_number"], | ||||||
|  |                     }), | ||||||
|  |                 }), | ||||||
|  |                 'digest_algorithm': algos.DigestAlgorithm({'algorithm': 'sha1'}), | ||||||
|  |                 'signature_algorithm': algos.SignedDigestAlgorithm({'algorithm': 'rsassa_pkcs1v15'}), | ||||||
|  |                 'signature': b64decode(authority.signer_exec("sign-pkcs7", b64encode(b"\x31" + attrs.dump()[1:]))) | ||||||
|  |             }) | ||||||
|  |  | ||||||
|  |             resp.append_header("Content-Type", "application/x-pki-message") | ||||||
|  |             resp.body = cms.ContentInfo({ | ||||||
|  |                 'content_type': 'signed_data', | ||||||
|  |                 'content': cms.SignedData({ | ||||||
|  |                     'version': 'v1', | ||||||
|  |                     'certificates': [x509.Certificate.load(server_certificate.asn1.dump())], # wat | ||||||
|  |                     'digest_algorithms': [cms.DigestAlgorithm({ | ||||||
|  |                         'algorithm':'sha1' | ||||||
|  |                     })], | ||||||
|  |                     'encap_content_info': { | ||||||
|  |                         'content_type': 'data', | ||||||
|  |                         'content': encrypted_container | ||||||
|  |                     }, | ||||||
|  |                     'signer_infos': [signer] | ||||||
|  |                 }) | ||||||
|  |             }).dump() | ||||||
| @@ -12,6 +12,7 @@ from cryptography import x509 | |||||||
| from cryptography.x509.oid import NameOID, ExtensionOID, ExtendedKeyUsageOID | from cryptography.x509.oid import NameOID, ExtensionOID, ExtendedKeyUsageOID | ||||||
| from cryptography.hazmat.primitives.asymmetric import rsa | from cryptography.hazmat.primitives.asymmetric import rsa | ||||||
| from cryptography.hazmat.primitives import hashes, serialization | from cryptography.hazmat.primitives import hashes, serialization | ||||||
|  | from cryptography.hazmat.primitives.serialization import Encoding | ||||||
| from certidude import config, push, mailer, const | from certidude import config, push, mailer, const | ||||||
| from certidude import errors | from certidude import errors | ||||||
| from jinja2 import Template | from jinja2 import Template | ||||||
| @@ -81,7 +82,13 @@ def store_request(buf, overwrite=False): | |||||||
|     if not buf: |     if not buf: | ||||||
|         raise ValueError("No signing request supplied") |         raise ValueError("No signing request supplied") | ||||||
|  |  | ||||||
|  |     if isinstance(buf, unicode): | ||||||
|         csr = x509.load_pem_x509_csr(buf, backend=default_backend()) |         csr = x509.load_pem_x509_csr(buf, backend=default_backend()) | ||||||
|  |     elif isinstance(buf, str): | ||||||
|  |         csr = x509.load_der_x509_csr(buf, backend=default_backend()) | ||||||
|  |         buf = csr.public_bytes(Encoding.PEM) | ||||||
|  |     else: | ||||||
|  |         raise ValueError("Invalid type, expected str for PEM and bytes for DER") | ||||||
|     common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) |     common_name, = csr.subject.get_attributes_for_oid(NameOID.COMMON_NAME) | ||||||
|     # TODO: validate common name again |     # TODO: validate common name again | ||||||
|  |  | ||||||
| @@ -92,7 +99,7 @@ def store_request(buf, overwrite=False): | |||||||
|  |  | ||||||
|  |  | ||||||
|     # If there is cert, check if it's the same |     # If there is cert, check if it's the same | ||||||
|     if os.path.exists(request_path): |     if os.path.exists(request_path) and not overwrite: | ||||||
|         if open(request_path).read() == buf: |         if open(request_path).read() == buf: | ||||||
|             raise errors.RequestExists("Request already exists") |             raise errors.RequestExists("Request already exists") | ||||||
|         else: |         else: | ||||||
| @@ -106,7 +113,7 @@ def store_request(buf, overwrite=False): | |||||||
|     mailer.send("request-stored.md", |     mailer.send("request-stored.md", | ||||||
|         attachments=(attach_csr,), |         attachments=(attach_csr,), | ||||||
|         common_name=common_name.value) |         common_name=common_name.value) | ||||||
|     return csr |     return csr, common_name.value | ||||||
|  |  | ||||||
|  |  | ||||||
| def signer_exec(cmd, *bits): | def signer_exec(cmd, *bits): | ||||||
|   | |||||||
| @@ -32,6 +32,8 @@ AUTOSIGN_SUBNETS = set([ipaddress.ip_network(j) for j in | |||||||
|     cp.get("authorization", "autosign subnets").split(" ") if j]) |     cp.get("authorization", "autosign subnets").split(" ") if j]) | ||||||
| REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in | REQUEST_SUBNETS = set([ipaddress.ip_network(j) for j in | ||||||
|     cp.get("authorization", "request subnets").split(" ") if j]).union(AUTOSIGN_SUBNETS) |     cp.get("authorization", "request subnets").split(" ") if j]).union(AUTOSIGN_SUBNETS) | ||||||
|  | SCEP_SUBNETS = set([ipaddress.ip_network(j) for j in | ||||||
|  |     cp.get("authorization", "scep subnets").split(" ") if j]) | ||||||
|  |  | ||||||
| AUTHORITY_DIR = "/var/lib/certidude" | AUTHORITY_DIR = "/var/lib/certidude" | ||||||
| AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path") | AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path") | ||||||
|   | |||||||
| @@ -5,11 +5,13 @@ import socket | |||||||
| import os | import os | ||||||
| import asyncore | import asyncore | ||||||
| import asynchat | import asynchat | ||||||
|  | from base64 import b64decode, b64encode | ||||||
| from certidude import const, config | from certidude import const, config | ||||||
| from cryptography import x509 | from cryptography import x509 | ||||||
| from cryptography.hazmat.backends import default_backend | from cryptography.hazmat.backends import default_backend | ||||||
| from cryptography.hazmat.primitives import hashes, serialization | from cryptography.hazmat.primitives import hashes, padding, serialization | ||||||
| from cryptography.hazmat.primitives.serialization import Encoding | from cryptography.hazmat.primitives.serialization import Encoding | ||||||
|  | from cryptography.hazmat.primitives.asymmetric import padding | ||||||
| from datetime import datetime, timedelta | from datetime import datetime, timedelta | ||||||
| from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID | from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID, AuthorityInformationAccessOID | ||||||
| import random | import random | ||||||
| @@ -64,6 +66,18 @@ class SignHandler(asynchat.async_chat): | |||||||
|             self.close() |             self.close() | ||||||
|             raise asyncore.ExitNow() |             raise asyncore.ExitNow() | ||||||
|  |  | ||||||
|  |         elif cmd == "sign-pkcs7": | ||||||
|  |             signer = self.server.private_key.signer( | ||||||
|  |                 padding.PKCS1v15(), | ||||||
|  |                 hashes.SHA1() | ||||||
|  |             ) | ||||||
|  |             signer.update(b64decode(body)) | ||||||
|  |             self.send(b64encode(signer.finalize())) | ||||||
|  |  | ||||||
|  |         elif cmd == "decrypt-pkcs7": | ||||||
|  |             self.send(b64encode(self.server.private_key.decrypt(b64decode(body), padding.PKCS1v15()))) | ||||||
|  |             self.close() | ||||||
|  |  | ||||||
|         elif cmd == "sign-request": |         elif cmd == "sign-request": | ||||||
|             # Only common name and public key are used from request |             # Only common name and public key are used from request | ||||||
|             request = x509.load_pem_x509_csr(body, default_backend()) |             request = x509.load_pem_x509_csr(body, default_backend()) | ||||||
|   | |||||||
| @@ -56,6 +56,9 @@ request subnets = 0.0.0.0/0 | |||||||
| # Certificates are automatically signed for these subnets | # Certificates are automatically signed for these subnets | ||||||
| autosign subnets = 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 | autosign subnets = 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 | ||||||
|  |  | ||||||
|  | # Simple Certificate Enrollment Protocol enabled subnets | ||||||
|  | scep subnets = | ||||||
|  |  | ||||||
| [logging] | [logging] | ||||||
| # Disable logging | # Disable logging | ||||||
| ;backend = | ;backend = | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user