Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- # This program is free software: you can redistribute it and/or modify
- # it under the terms of the GNU Lesser General Public License as published
- # by the Free Software Foundation, either version 3 of the License, or
- # (at your option) any later version.
- #
- # This program is distributed in the hope that it will be useful,
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- # GNU General Public License for more details.
- #
- # You should have received a copy of the GNU Lesser General Public License
- # along with this program. If not, see <https://www.gnu.org/licenses/>.
- # Acknowledgment
- # This program uses the following open source projects:
- # BitString, by Scott Griffiths, released under MIT License
- # Pony ORM, by Pony ORM, LLC, released under Apache v2 License
- # Requests, by Kenneth Reitz, released under Apache v2 License
- from socket import socket, AF_INET, SOCK_DGRAM
- import queue
- from threading import Thread
- import requests
- from bitstring import BitArray, BitStream
- import pony.orm
- from time import sleep, time
- from collections import deque
- SERVER_IP = '127.0.0.1'
- SERVER_PORT = 53
- DOH_URL = 'https://1.1.1.1/dns-query'
- PERIOD = 0.5 # In days
- POSITIVE_CACHE_FILENAME = 'dns_pcache.sqlite'
- NEGATIVE_CACHE_FILENAME = 'dns_ncache.sqlite'
- SCHEDULE_FILE_FILENAME = 'cache_update.schedule'
- def main():
- if PERIOD <= 0:
- raise ValueError('PERIOD should be larger than 0.')
- # Load records cache database
- records_cache = pony.orm.Database()
- class RecordsCache(records_cache.Entity):
- full_domain = pony.orm.Required(str)
- query_type = pony.orm.Required(str)
- data = pony.orm.Required(str)
- used_in_current_period = pony.orm.Required(bool)
- used_in_previous_period = pony.orm.Required(bool)
- pony.orm.PrimaryKey(full_domain, query_type)
- records_cache.bind(provider='sqlite', filename=POSITIVE_CACHE_FILENAME, create_db=True)
- records_cache.generate_mapping(create_tables=True)
- # Create negative cache database
- negative_cache = pony.orm.Database()
- class NegativeCache(negative_cache.Entity):
- full_domain = pony.orm.PrimaryKey(str)
- negative_cache.bind(provider='sqlite', filename=NEGATIVE_CACHE_FILENAME, create_db=True)
- negative_cache.generate_mapping(create_tables=True)
- with pony.orm.db_session:
- NegativeCache.select().delete(bulk=True) # Delete all negative cache from last session
- try:
- schedule_file = open(file=SCHEDULE_FILE_FILENAME, mode='r+')
- schedule = schedule_file.read().rstrip()
- try:
- schedule = int(schedule)
- except ValueError as e:
- print(e)
- schedule = int(time() + PERIOD * 86400)
- schedule_file.seek(0)
- schedule_file.truncate()
- schedule_file.writelines(str(schedule)+'\n')
- except FileNotFoundError:
- schedule_file = open(file=SCHEDULE_FILE_FILENAME, mode='w+')
- schedule = int(time() + PERIOD * 86400)
- schedule_file.writelines(str(schedule)+'\n')
- finally:
- schedule_file.close()
- Thread(target=timer_job, args=(schedule, RecordsCache, NegativeCache)).start() # Timer thread
- task_queue = queue.Queue()
- server_socket = socket(AF_INET, SOCK_DGRAM)
- server_socket.bind((SERVER_IP, SERVER_PORT))
- for i in range(4):
- Thread(target=worker, args=(task_queue, RecordsCache, NegativeCache, server_socket)).start() # Worker threads
- while True:
- query_data, client_address = server_socket.recvfrom(512)
- task_package = (query_data, client_address)
- task_queue.put(task_package)
- def worker(task_queue, RecordsCache, NegativeCache, server_socket):
- requests_session = requests.Session()
- while True:
- query_data, client_address = task_queue.get(block=True) # Using block=False will result in high cpu usage
- try:
- full_domain, query_type, id, rd, cd, question_section = read_query_stream(BitStream(query_data))
- print('Received query:', full_domain, query_type)
- if query_type != 'A':
- if query_type == 'ALL':
- query_type = 'A'
- else:
- raise NotImplementedError('Received query for {} record. The current server supports A records only.'.format(query_type))
- except BaseException as e:
- print(e)
- task_queue.task_done()
- continue # Skip following procedures, go to next iteration
- try:
- cache_status, cached_data = fetch_cached_data(RecordsCache, NegativeCache, full_domain, query_type)
- except BaseException as e:
- print(e)
- task_queue.task_done()
- continue # Skip following procedures, go to next iteration
- status_to_respond = ''
- address_data = None
- if cache_status == 'positive': # There is cache and it is valid record
- address_data = cached_data
- response = construct_positive_response(id, rd, cd, question_section, address_data)
- status_to_respond = 'NOERROR'
- elif cache_status == 'nxdomain':
- response = construct_nxdomain_response(id, rd, cd, question_section)
- status_to_respond = 'NXDOMAIN'
- elif cache_status == 'nocache':
- try:
- remote_status, remote_data = fetch_remote_data(requests_session, full_domain, query_type)
- except BaseException as e:
- # Return server failure
- print('Failed to get address from remote:', e)
- response = construct_servfail_response(id, rd, cd, question_section)
- status_to_respond = 'SERVFAIL'
- else:
- if remote_status == 'noerror': # Successfully retrieved address from remote
- address_data = remote_data
- Thread(target=error_output_beautifier, args=(cache_remote_positive_answer, (RecordsCache, full_domain, query_type, remote_data))).start()
- response = construct_positive_response(id, rd, cd, question_section, address_data)
- status_to_respond = 'NOERROR'
- elif remote_status == 'nodata':
- response = construct_nodata_response(id, rd, cd, question_section)
- status_to_respond = 'NODATA'
- elif remote_status == 'nxdomain':
- Thread(target=error_output_beautifier, args=(cache_remote_nxdomain_answer, (NegativeCache, full_domain))).start()
- response = construct_nxdomain_response(id, rd, cd, question_section)
- status_to_respond = 'NXDOMAIN'
- elif remote_status == 'servfail':
- response = construct_servfail_response(id, rd, cd, question_section)
- status_to_respond = 'SERVFAIL'
- else:
- raise NotImplementedError('Unrecognized remote_status.')
- else:
- raise Exception('Invalid cache_status.')
- print('Responding message:', full_domain, query_type, str(address_data), status_to_respond)
- binary_response = response.tobytes()
- server_socket.sendto(binary_response, client_address)
- task_queue.task_done()
- # Return server failure when both having no cache and fetching from remote fails
- # Return no such domain when there are negative cache or remote server says so
- # Return no error with cached record or remotely fetched record
- # Otherwise return server failure
- def read_query_stream(query_stream): # query_stream should be a BitStream
- # Read header
- id, qr, opcode, aa, tc, rd, ra, z, ad, cd, rcode, qdcount, ancount, nscount, arcount = query_stream.readlist(
- 'bits:16, bool, uint:4, bool, bool, bits:1, bool, bits:1, bool, bits:1, uint:4, uint:16, uint:16, uint:16, uint:16')
- # Initial checks on whether the query is legitimate or supported
- if qr:
- raise ValueError('Value of QR should have been 0 as it should be a query.')
- if opcode != 0:
- raise NotImplementedError('Non-standard queries are not supported.')
- if qdcount != 1:
- raise ValueError('The current server only supports query with single question.')
- if ancount != 0:
- raise ValueError('There should be no answer records in query.')
- if nscount != 0:
- raise ValueError('There should be no authority records in query.')
- if aa:
- raise ValueError('Unexpected AA flag being set.')
- if ra:
- raise ValueError('Unexpected RA flag being set.')
- # Read question
- questions_start_pos = query_stream.pos # Used in copying the question section
- domain_labels = []
- while True:
- label_flag = query_stream.read('bits:2')
- if label_flag.bin == '00':
- label_length = query_stream.read('uint:6')
- if label_length > 0:
- label = query_stream.read('bytes:' + str(label_length))
- domain_labels.append(label)
- else:
- break
- else:
- raise ValueError('Question name label flag unrecognized.')
- full_domain = '.'.join([str(label, 'utf-8') for label in domain_labels])
- if not full_domain.endswith('.'):
- full_domain = full_domain + '.'
- full_domain = full_domain.lower() # Convert to lower case
- query_type_value = query_stream.read('uint:16')
- record_type_dict = {1: 'A', 2: 'NS', 3: 'MD', 4: 'MF', 5: 'CNAME', 6: 'SOA', 7: 'MB', 8: 'MG',
- 9: 'MR', 10: 'NULL', 11: 'WKS', 12: 'PTR', 13: 'HINFO', 14: 'MINFO', 15: 'MX',
- 16: 'TXT', 17: 'RP', 18: 'AFSDB', 19: 'X25', 20: 'ISDN', 21: 'RT', 22: 'NSAP',
- 23: 'NSAP-PTR', 24: 'SIG', 25: 'KEY', 26: 'PX', 27: 'GPOS', 28: 'AAAA',
- 29: 'LOC', 30: 'NXT', 31: 'EID', 32: 'NIMLOC', 33: 'SRV', 34: 'ATMA',
- 35: 'NAPTR', 36: 'KX', 37: 'CERT', 38: 'A6', 39: 'DNAME', 40: 'SINK',
- 41: 'OPT', 42: 'APL', 43: 'DS', 44: 'SSHFP', 45: 'IPSECKEY', 46: 'RRSIG',
- 47: 'NSEC', 48: 'DNSKEY', 49: 'DHCID', 50: 'NSEC3', 51: 'NSEC3PARAM',
- 52: 'TLSA', 53: 'SMIMEA', 55: 'HIP', 59: 'CDS', 60: 'CDSKEY',
- 61: 'OPENGPGKEY', 99: 'SPF', 100: 'UINFO', 101: 'UID', 102: 'GID',
- 103: 'UNSPEC', 249: 'TKEY', 250: 'TSIG', 251: 'IXFR', 252: 'AXFR',
- 253: 'MAILB', 254: 'MAILA', 255: 'ALL', 256: 'URI', 257: 'CAA', 32768: 'TA',
- 32769: 'DLV'}
- query_type = record_type_dict[query_type_value]
- query_class_value = query_stream.read('uint:16')
- if query_class_value != 1:
- raise NotImplementedError('The current server supports Internet class only.')
- questions_end_pos = query_stream.pos
- question_section = query_stream[questions_start_pos:questions_end_pos]
- # Ignore other sections
- return full_domain, query_type, id, rd, cd, question_section
- def fetch_cached_data(RecordsCache, NegativeCache, full_domain, query_type):
- with pony.orm.db_session:
- cached_record = RecordsCache.get(full_domain=full_domain, query_type=query_type)
- if cached_record:
- cached_record.used_in_current_period = True
- cached_address = cached_record.data
- return 'positive', cached_address
- if NegativeCache.exists(full_domain=full_domain):
- return 'nxdomain', None
- return 'nocache', None
- def fetch_remote_data(requests_session, full_domain, query_type):
- result = requests_session.get(DOH_URL, params={'name': full_domain, 'type': query_type},
- headers={'Accept': 'application/dns-json'}, timeout=10)
- result_json = result.json()
- status_code = int(result_json['Status']) # 0 = no error, 1 = format error, 2 = server failure,
- # 3 = no such domain, 4 = not implemented, 5 = refused
- if status_code == 0: # Received NOERROR
- try:
- answers = result_json['Answer']
- for answer in answers:
- if query_type == 'A':
- if answer['type'] == 1:
- return 'noerror', answer['data']
- except KeyError:
- pass
- # If there is no data in Answer section or no answer section
- return 'nodata', None
- elif status_code == 3:
- return 'nxdomain', None
- elif status_code == 2:
- return 'servfail', None
- else:
- raise NotImplementedError('Unsupported status code received.')
- def cache_remote_positive_answer(RecordsCache, full_domain, query_type, remote_data):
- with pony.orm.db_session:
- # Automatic handling by ponyorm
- RecordsCache(full_domain=full_domain, query_type=query_type, data=remote_data,
- used_in_current_period=True,
- used_in_previous_period=False)
- print('Cached record:', full_domain, query_type, remote_data)
- def cache_remote_nxdomain_answer(NegativeCache, full_domain):
- with pony.orm.db_session:
- # Automatic handling by ponyorm
- NegativeCache(full_domain=full_domain)
- print('Cached NXDOMAIN:', full_domain)
- def construct_positive_response(id, rd, cd, question_section, address_data):
- # See https://tools.ietf.org/html/rfc1035#section-4.1.1
- # Also https://tools.ietf.org/html/rfc6895
- response = BitArray()
- # Header section begins
- header = BitArray()
- id = id # 16bits ID identifier
- qr = BitArray('uint:1=1') # 1bit specifying if it's query(0) or response(1)
- opcode = BitArray('uint:4=0') # 4bits specifying query kind. 0 = standard
- aa = BitArray('uint:1=0') # 1bit specifying if response is authorative(1)
- tc = BitArray('uint:1=0') # 1bit specifying if message is truncated(1)
- rd = rd # Copy from query, 1bit specifying if recursion is desired(1) by sender
- ra = BitArray('uint:1=0') # 1bit specifying if recursion is available(1) from server
- z = BitArray('uint:1=0') # 1bit reserved for future use, zero
- ad = BitArray('uint:1=0') # 1bit stating all records in answer and authority sections are authentic (DNSSEC)
- cd = cd # Copy from query, 1bit requesting no signature validation by upstream servers (DNSSEC)
- rcode = BitArray('uint:4=0') # 4bits stating query status, 0 = no error, 1 = format error,
- # 2 = server failure, 3 = name error, 4 = not implemented,
- # 5 = refused, 6-15 = reserved
- qdcount = BitArray(uint=1, length=16) # unsigned 16bits int specifying number of entries in question section
- ancount = BitArray(uint=1, length=16) # unsigned 16bits int specifying number of records in answer section
- nscount = BitArray('uint:16=0') # unsigned 16bits int specifying number of records in authority section
- arcount = BitArray('uint:16=0') # unsigned 16bits int specifying number of records in additional record section
- header.append(id)
- header.append(qr)
- header.append(opcode)
- header.append(aa)
- header.append(tc)
- header.append(rd)
- header.append(ra)
- header.append(z)
- header.append(ad)
- header.append(cd)
- header.append(rcode)
- header.append(qdcount)
- header.append(ancount)
- header.append(nscount)
- header.append(arcount)
- response.append(header)
- # Header section ends
- # Question section, copied from query
- response.append(question_section)
- # Answer section begins
- ttl = abs(int(PERIOD * 86400))
- offset_starting_point = 12 # It can be 12 only assuming query only has one question
- rr_part = BitArray()
- # NAME
- rr_part.append(BitArray('uint:2=3')) # 2bits, '11', flag for compression
- rr_part.append(BitArray(uint=offset_starting_point, length=14)) # 14bit, offset specified in compression
- # TYPE
- rr_part.append(BitArray('uint:16=1')) # A record
- # CLASS
- rr_part.append(BitArray('uint:16=1')) # Internet Class
- # TTL
- rr_part.append(BitArray(uint=ttl, length=32)) # TTL in seconds
- # RDLENGTH, specifying length of data
- rr_part.append(BitArray('uint:16=4')) # IP address in A record requires 4 octets(bytes)
- # RDATA
- ipaddress = [BitArray(uint=int(part), length=8) for part in address_data.split('.')] # Like socket.inet_aton()
- for part in ipaddress:
- rr_part.append(part)
- response.append(rr_part)
- # Answer section ends
- # Ignore other sections
- return response
- def construct_nodata_response(id, rd, cd, question_section):
- # See https://tools.ietf.org/html/rfc1035#section-4.1.1
- # Also https://tools.ietf.org/html/rfc6895
- response = BitArray()
- # Header section begins
- header = BitArray()
- id = id # 16bits ID identifier
- qr = BitArray('uint:1=1') # 1bit specifying if it's query(0) or response(1)
- opcode = BitArray('uint:4=0') # 4bits specifying query kind. 0 = standard
- aa = BitArray('uint:1=0') # 1bit specifying if response is authorative(1)
- tc = BitArray('uint:1=0') # 1bit specifying if message is truncated(1)
- rd = rd # Copy from query, 1bit specifying if recursion is desired(1) by sender
- ra = BitArray('uint:1=0') # 1bit specifying if recursion is available(1) from server
- z = BitArray('uint:1=0') # 1bit reserved for future use, zero
- ad = BitArray('uint:1=0') # 1bit stating all records in answer and authority sections are authentic (DNSSEC)
- cd = cd # Copy from query, 1bit requesting no signature validation by upstream servers (DNSSEC)
- rcode = BitArray('uint:4=0') # 4bits stating query status, 0 = no error, 1 = format error,
- # 2 = server failure, 3 = name error, 4 = not implemented,
- # 5 = refused, 6-15 = reserved
- # In NODATA scenario, it should be 0
- qdcount = BitArray(uint=1, length=16) # unsigned 16bits int specifying number of entries in question section
- ancount = BitArray(uint=0, length=16) # unsigned 16bits int specifying number of records in answer section. In NODATA scenario, it should be 0
- nscount = BitArray('uint:16=0') # unsigned 16bits int specifying number of records in authority section
- arcount = BitArray('uint:16=0') # unsigned 16bits int specifying number of records in additional record section
- header.append(id)
- header.append(qr)
- header.append(opcode)
- header.append(aa)
- header.append(tc)
- header.append(rd)
- header.append(ra)
- header.append(z)
- header.append(ad)
- header.append(cd)
- header.append(rcode)
- header.append(qdcount)
- header.append(ancount)
- header.append(nscount)
- header.append(arcount)
- response.append(header)
- # Header section ends
- # Question section, copied from query
- response.append(question_section)
- # No Answer section in NODATA scenario
- # Ignore other sections
- return response
- def construct_nxdomain_response(id, rd, cd, question_section):
- # See https://tools.ietf.org/html/rfc1035#section-4.1.1
- # Also https://tools.ietf.org/html/rfc6895
- response = BitArray()
- # Header section begins
- header = BitArray()
- id = id # 16bits ID identifier
- qr = BitArray('uint:1=1') # 1bit specifying if it's query(0) or response(1)
- opcode = BitArray('uint:4=0') # 4bits specifying query kind. 0 = standard
- aa = BitArray('uint:1=0') # 1bit specifying if response is authorative(1)
- tc = BitArray('uint:1=0') # 1bit specifying if message is truncated(1)
- rd = rd # Copy from query, 1bit specifying if recursion is desired(1) by sender
- ra = BitArray('uint:1=0') # 1bit specifying if recursion is available(1) from server
- z = BitArray('uint:1=0') # 1bit reserved for future use, zero
- ad = BitArray('uint:1=0') # 1bit stating all records in answer and authority sections are authentic (DNSSEC)
- cd = cd # Copy from query, 1bit requesting no signature validation by upstream servers (DNSSEC)
- rcode = BitArray('uint:4=3') # 4bits stating query status, 0 = no error, 1 = format error,
- # 2 = server failure, 3 = name error, 4 = not implemented,
- # 5 = refused, 6-15 = reserved
- # In NXDOMAIN scenario, it should be 3
- qdcount = BitArray(uint=1, length=16) # unsigned 16bits int specifying number of entries in question section
- ancount = BitArray(uint=0, length=16) # unsigned 16bits int specifying number of records in answer section
- nscount = BitArray('uint:16=0') # unsigned 16bits int specifying number of records in authority section
- arcount = BitArray('uint:16=0') # unsigned 16bits int specifying number of records in additional record section
- header.append(id)
- header.append(qr)
- header.append(opcode)
- header.append(aa)
- header.append(tc)
- header.append(rd)
- header.append(ra)
- header.append(z)
- header.append(ad)
- header.append(cd)
- header.append(rcode)
- header.append(qdcount)
- header.append(ancount)
- header.append(nscount)
- header.append(arcount)
- response.append(header)
- # Header section ends
- # Question section, copied from query
- response.append(question_section)
- # No Answer section in NXDOMAIN scenario
- # Ignore other sections
- return response
- def construct_servfail_response(id, rd, cd, question_section):
- # See https://tools.ietf.org/html/rfc1035#section-4.1.1
- # Also https://tools.ietf.org/html/rfc6895
- response = BitArray()
- # Header section begins
- header = BitArray()
- id = id # 16bits ID identifier
- qr = BitArray('uint:1=1') # 1bit specifying if it's query(0) or response(1)
- opcode = BitArray('uint:4=0') # 4bits specifying query kind. 0 = standard
- aa = BitArray('uint:1=0') # 1bit specifying if response is authorative(1)
- tc = BitArray('uint:1=0') # 1bit specifying if message is truncated(1)
- rd = rd # Copy from query, 1bit specifying if recursion is desired(1) by sender
- ra = BitArray('uint:1=0') # 1bit specifying if recursion is available(1) from server
- z = BitArray('uint:1=0') # 1bit reserved for future use, zero
- ad = BitArray('uint:1=0') # 1bit stating all records in answer and authority sections are authentic (DNSSEC)
- cd = cd # Copy from query, 1bit requesting no signature validation by upstream servers (DNSSEC)
- rcode = BitArray('uint:4=2') # 4bits stating query status, 0 = no error, 1 = format error,
- # 2 = server failure, 3 = name error, 4 = not implemented,
- # 5 = refused, 6-15 = reserved
- # In SERVFAIL scenario, it should be 2
- qdcount = BitArray(uint=1, length=16) # unsigned 16bits int specifying number of entries in question section
- ancount = BitArray(uint=0, length=16) # unsigned 16bits int specifying number of records in answer section
- nscount = BitArray('uint:16=0') # unsigned 16bits int specifying number of records in authority section
- arcount = BitArray('uint:16=0') # unsigned 16bits int specifying number of records in additional record section
- header.append(id)
- header.append(qr)
- header.append(opcode)
- header.append(aa)
- header.append(tc)
- header.append(rd)
- header.append(ra)
- header.append(z)
- header.append(ad)
- header.append(cd)
- header.append(rcode)
- header.append(qdcount)
- header.append(ancount)
- header.append(nscount)
- header.append(arcount)
- response.append(header)
- # Header section ends
- # Question section, copied from query
- response.append(question_section)
- # No Answer section in SERVFAIL scenario
- # Ignore other sections
- return response
- def change_period(RecordsCache, NegativeCache):
- print('Starting to change period.')
- with pony.orm.db_session:
- # Delete cached records unused in both current period and previous period
- RecordsCache.select(lambda record: (not record.used_in_current_period) and (not record.used_in_previous_period)).delete(bulk=True)
- # Update used_in_current_period and used_in_previous_period
- cached_records = RecordsCache.select()
- for record in cached_records:
- record.used_in_previous_period = record.used_in_current_period
- record.used_in_current_period = False
- # Delete all negative cache
- NegativeCache.select().delete(bulk=True)
- print('Unused records discarded, negative cache deleted, and period columns updated in database.')
- update_cache(RecordsCache, NegativeCache)
- def update_cache(RecordsCache, NegativeCache):
- with pony.orm.db_session:
- cached_records = RecordsCache.select()
- records_to_update = deque((record.full_domain, record.query_type) for record in cached_records)
- requests_session = requests.Session()
- while True:
- try:
- record = records_to_update.popleft()
- except IndexError:
- break
- else:
- full_domain, query_type = record[0], record[1]
- try:
- remote_status, remote_data = fetch_remote_data(requests_session, full_domain, query_type)
- except BaseException as e:
- print(e)
- print('Cache update failed, re-enqueue task:', full_domain, query_type)
- records_to_update.append(record)
- else:
- if remote_status == 'noerror':
- try:
- with pony.orm.db_session:
- RecordsCache.get(full_domain=full_domain, query_type=query_type).set(data=remote_data)
- except BaseException as e:
- print(e)
- else:
- print('Updated cache:', full_domain, query_type, remote_data)
- else:
- print('Cache update failed, re-enqueue task:', full_domain, query_type)
- records_to_update.append(record)
- sleep(5)
- def update_schedule_file(schedule):
- try:
- with open(file=SCHEDULE_FILE_FILENAME, mode='w+') as schedule_file:
- schedule_file.writelines(str(schedule)+'\n')
- except BaseException as e:
- print(e)
- def timer_job(schedule, RecordsCache, NegativeCache):
- print('Timer job started')
- while True:
- print('Seconds to next period change:', int(schedule - time()))
- sleep(int(schedule - time()))
- change_period(RecordsCache, NegativeCache)
- schedule = int(time() + PERIOD * 86400)
- update_schedule_file(schedule)
- def error_output_beautifier(function, args):
- try:
- function(*args)
- except BaseException as e:
- print(e)
- if __name__ == '__main__':
- main()
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement