diff --git a/pushserver/resources/settings.py b/pushserver/resources/settings.py index adc6e37..c8515a6 100644 --- a/pushserver/resources/settings.py +++ b/pushserver/resources/settings.py @@ -1,308 +1,308 @@ import configparser import logging import os from ipaddress import ip_network from pushserver.pns.register import get_pns_from_config from application import log class ConfigParams(object): """ Settings params to share across modules. :param dir: `dict` with 'path' to config dir an 'error' if exists :param file: `dict` with 'path' to config file :param server: `dict` with host, port and tls_cert from config file :param apps: `dict` with path, credentials and extra_dir from config file :param loggers: `dict` global logging instances to write messages (params.loggers) :param allowed_pool: `list` of allowed hosts for requests if there is any error with config dir or config file, others params will be setted to None. """ def __init__(self, config_dir, debug, ip, port): self.default_host, self.default_port = '127.0.0.1', '8400' self.config_dir = config_dir self.debug = debug self.ip, self.port = ip, port self.cfg_file = f'general.ini' self.dir = self.set_dir() self.file = self.set_file() self.loggers = self.set_loggers() self.apps = self.set_apps() self.register = self.set_register() self.allowed_pool = self.set_allowed_pool() self.return_async = self.set_return_async() def set_dir(self): """ if config directory was not specified from command line look for general.ini in /etc/sylk-pushserver if general.ini is not there, server will start with default settings """ dir, error = {}, '' config_dir = self.config_dir msg = f"Reading configuration from {config_dir}" log.info(msg) if not os.path.exists(f'{self.config_dir}/{self.cfg_file}'): config_dir = '' error = f'No {self.cfg_file} found in {self.config_dir}, ' \ f'server will run with default settings.' dir['path'], dir['error'] = config_dir, error return dir def set_file(self): file, path, error = {}, '', '' if not self.dir.get('error'): path = f"{self.dir['path']}/{self.cfg_file}" error = '' elif 'default' in self.dir.get('error'): path = '' error = self.dir.get('error') file['path'], file['error'] = path, error return file @property def server(self): server = {} if not self.file.get('error') or 'default' in self.file.get('error'): config = configparser.ConfigParser() config.read(self.file['path']) try: server_settings = config['server'] except KeyError: server_settings = {} if self.ip: server['host'] = self.ip else: server['host'] = server_settings.get('host') or self.default_host if self.port: server['port'] = self.port else: server['port'] = server_settings.get('port') or self.default_port server['tls_cert'] = server_settings.get('tls_certificate') or '' return server def set_apps(self): apps = {} apps_path = f'{self.config_dir}/applications.ini' apps_cred = f'{self.config_dir}/credentials' apps_extra_dir = f'{self.config_dir}/applications' pns_extra_dir = f'{self.config_dir}/pns' if self.file['path']: logging.info(f"Reading: {self.file['path']}") config = configparser.ConfigParser() config.read(self.file['path']) config_apps_path = f"{config['applications'].get('config_file')}" config_apps_cred = f"{config['applications'].get('credentials_folder')}" config_apps_extra_dir = f"{config['applications'].get('extra_applications_dir')}" config_pns_extra_dir = f"{config['applications'].get('extra_pns_dir')}" paths_list = [config_apps_path, config_apps_cred, config_apps_extra_dir, config_pns_extra_dir] for i, path in enumerate(paths_list): if not path.startswith('/'): paths_list[i] = f'{self.config_dir}/{path}' config_apps_path = paths_list[0] config_apps_cred = paths_list[1] config_apps_extra_dir = paths_list[2] config_pns_extra_dir = paths_list[3] apps_path_exists = os.path.exists(config_apps_path) cred_path_exists = os.path.exists(config_apps_cred) extra_apps_dir_exists = os.path.exists(config_apps_extra_dir) extra_pns_dir_exists = os.path.exists(config_pns_extra_dir) if apps_path_exists: apps_path = config_apps_path if cred_path_exists: apps_cred = config_apps_cred if extra_apps_dir_exists: apps_extra_dir = config_apps_extra_dir if extra_pns_dir_exists: pns_extra_dir = config_pns_extra_dir else: logging.info(self.dir['error']) if not os.path.exists(apps_path): self.dir['error'] = f'Required config file not found: {apps_path}' apps_path, apps_cred, apps_extra_dir = '', '', '' else: logging.info(f'Reading: {apps_path}') config = configparser.ConfigParser() config.read(apps_path) if config.sections(): for id in config.sections(): try: config[id]['app_id'] config[id]['app_type'] config[id]['app_platform'].lower() except KeyError: self.dir['error'] = f'Can not start: ' \ f'{apps_path} config file has not ' \ f'valid application settings' apps_path, apps_cred, apps_extra_dir = '', '', '' apps['path'] = apps_path apps['credentials'] = apps_cred apps['apps_extra_dir'] = apps_extra_dir apps['pns_extra_dir'] = pns_extra_dir return apps def set_loggers(self): debug = self.debug if self.debug else False loggers = {} config = configparser.ConfigParser() default_path = '/var/log/sylk-pushserver/push.log' log_path = '' if not self.file['error']: config.read(self.file['path']) try: log_to_file = f"{config['server']['log_to_file']}" log_to_file = True if log_to_file.lower() == 'true' else False except KeyError: pass else: if log_to_file: try: log_path = f"{config['server']['log_file']}" except KeyError: log_path = default_path try: str_debug = config['server']['debug'].lower() except KeyError: str_debug = False debug = True if str_debug == 'true' else False debug = debug or self.debug formatter = logging.Formatter('%(asctime)s [%(levelname)-8s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') logger_journal = logging.getLogger() loggers['to_journal'] = logger_journal if log_path: try: hdlr = logging.FileHandler(log_path) hdlr.setFormatter(formatter) hdlr.setLevel(logging.DEBUG) logger_journal.addHandler(hdlr) except PermissionError: log.warning(f'Permission denied for log file: {log_path}, ' \ f'logging will only be in the journal or foreground') debug = debug or self.debug - + loggers['debug'] = debug if debug: logger_journal.setLevel(logging.DEBUG) return loggers def set_register(self): if not self.dir['error'] or 'default' in self.dir['error']: apps_path, apps_cred = self.apps['path'], self.apps['credentials'] apps_extra_dir = self.apps['apps_extra_dir'] pns_extra_dir = self.apps['pns_extra_dir'] return get_pns_from_config(config_path=apps_path, credentials=apps_cred, apps_extra_dir=apps_extra_dir, pns_extra_dir=pns_extra_dir, loggers=self.loggers) @property def pns_register(self): return self.register['pns_register'] @property def invalid_apps(self): return self.register['invalid_apps'] @property def pnses(self): return self.register['pnses'] def set_allowed_pool(self): if self.dir['error']: return None if not self.file['path']: return None allowed_pool = [] allowed_hosts_str = '' config = configparser.ConfigParser() config.read(self.file['path']) try: allowed_hosts_str = config['server']['allowed_hosts'] allowed_hosts = allowed_hosts_str.split(', ') except KeyError: return allowed_pool except SyntaxError: error = f'allowed_hosts = {allowed_hosts_str} - bad syntax' self.dir['error'] = error return allowed_pool if type(allowed_hosts) not in (list, tuple): error = f'allowed_hosts = {allowed_hosts} - bad syntax' self.dir['error'] = error return allowed_pool config.read(self.file['path']) for addr in allowed_hosts: try: net = f'{addr}/32' if '/' not in addr else addr allowed_pool.append(ip_network(net)) except ValueError as e: error = f'wrong acl settings: {e}' self.dir['error'] = error return [] return set(allowed_pool) def set_return_async(self): return_async = True config = configparser.ConfigParser() if not self.file['error']: config.read(self.file['path']) try: return_async = config['server']['return_async'] return_async = True if return_async.lower() == 'true' else False except KeyError: return_async = True return return_async def init(config_dir, debug, ip, port): global params params = ConfigParams(config_dir, debug, ip, port) return params def update_params(config_dir, debug, ip, port): global params try: params = ConfigParams(config_dir, debug, ip, port) except Exception as ex: print(f'Settings can not be updated, reason: {ex}') return params diff --git a/pushserver/resources/utils.py b/pushserver/resources/utils.py index 397f2bc..e58ba69 100644 --- a/pushserver/resources/utils.py +++ b/pushserver/resources/utils.py @@ -1,373 +1,374 @@ import hashlib import json import logging import socket import ssl import time from ipaddress import ip_address from datetime import datetime __all__ = ['callid_to_uuid', 'fix_non_serializable_types', 'resources_available', 'ssl_cert', 'try_again', 'check_host', 'log_event', 'fix_device_id', 'fix_platform_name', 'log_incoming_request'] def callid_to_uuid(call_id: str) -> str: """ Generate a UUIDv4 from a callId. UUIDv4 format: five segments of seemingly random hex data, beginning with eight hex characters, followed by three four-character strings, then 12 characters at the end. These segments are separated by a “-”. :param call_id: `str` Globally unique identifier of a call. :return: a str with a uuidv4. """ hexa = hashlib.md5(call_id.encode()).hexdigest() uuidv4 = '%s-%s-%s-%s-%s' % \ (hexa[:8], hexa[8:12], hexa[12:16], hexa[16:20], hexa[20:]) return uuidv4 def fix_non_serializable_types(obj): """ Converts a non serializable object in an appropriate one, if it is possible and in a recursive way. If not, return the str 'No JSON Serializable object' :param obj: obj to convert """ if isinstance(obj, bytes): string = obj.decode() return fix_non_serializable_types(string) elif isinstance(obj, dict): return { fix_non_serializable_types(k): fix_non_serializable_types(v) for k, v in obj.items() } elif isinstance(obj, (tuple, list)): return [fix_non_serializable_types(elem) for elem in obj] elif isinstance(obj, str): try: dict_obj = json.loads(obj) return fix_non_serializable_types(dict_obj) except json.decoder.JSONDecodeError: return obj elif isinstance(obj, (bool, int, float)): return obj else: return def resources_available(host: str, port: int) -> bool: """ Check if a pair ip, port is available for a connection :param: `str` host :param: `int` port :return: a `bool` according to the test result. """ serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if not host or not port: return None try: serversocket.bind((host, port)) serversocket.close() return True except OSError: return False def ssl_cert(cert_file: str, key_file=None) -> bool: """ Check if a ssl certificate is valid. :param cert_file: `str` path to certificate file :param key_file: `str` path to key file :return: `bool` True for a valid certificate. """ ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE try: ssl_context.load_cert_chain(certfile=cert_file, keyfile=key_file) return True except (ssl.SSLError, NotADirectoryError, TypeError): return False def try_again(timer: int, host: str, port: int, start_error: str, loggers: dict) -> None: """ Sleep for a specific time and send log messages in case resources would not be available to start the app. :param timer: `int` time in seconds to wait (30 = DHCP delay) :param host: `str` IP address where app is trying to run :param port: `int` Host where app is trying to run :param start_error: `stṛ` Error msg to show in log. :param loggers: global logging instances to write messages (params.loggers) """ timer = timer # seconds, 30 for dhcp delay. level = 'error' msg = f"[can not init] on {host}:{port} - resources are not available" log_event(msg=start_error, level=level, loggers=loggers) log_event(msg=msg, level=level, loggers=loggers) msg = f'Server will try again in {timer} seconds' log_event(msg=msg, level=level, loggers=loggers) time.sleep(timer) def check_host(host, allowed_hosts) -> bool: """ Check if a host is in allowed_hosts :param host: `str` to check :return: `bool` """ if not allowed_hosts: return True for subnet in allowed_hosts: if ip_address(host) in subnet: return True return False def log_event(loggers: dict, msg: str, level: str = 'deb') -> None: """ Write log messages into log file and in journal if specified. :param loggers: `dict` global logging instances to write messages (params.loggers) :param msg: `str` message to write :param level: `str` info, error, deb or warn :param to_file: `bool` write just in file if True """ logger = loggers.get('to_journal') - + if logger.level != logging.DEBUG and loggers['debug'] is True: + logger.setLevel(logging.DEBUG) if level == 'info': logger.info(msg) elif level == 'error': logger.error(msg) elif level == 'warn': logger.warning(msg) elif level in ('deb', 'debug'): logger.debug(msg) def fix_device_id(device_id_to_fix: str) -> str: """ Remove special characters from uuid :param device_id_to_fix: `str` uuid with special characters. :return: a `str` with fixed uuid. """ if '>' in device_id_to_fix: uuid = device_id_to_fix.split(':')[-1].replace('>', '') elif ':' in device_id_to_fix: uuid = device_id_to_fix.split(':')[-1] else: uuid = device_id_to_fix device_id = uuid return device_id def fix_platform_name(platform: str) -> str: """ Fix platform name in case its value is 'android' or 'ios', replacing it for 'firebase' and 'apple' :param platform: `str` name of platform :return: a `str` with fixed name. """ if platform in ('firebase', 'android'): return 'firebase' elif platform in ('apple', 'ios'): return 'apple' else: return platform def fix_payload(body: dict) -> dict: payload = {} for item in body.keys(): value = body[item] if item in ('sip_to', 'sip_from'): item = item.split('_')[1] else: item = item.replace('_', '-') payload[item] = value return payload def pick_log_function(exc, *args, **kwargs): if ('rm_request' in exc.errors()[0]["loc"][1]): return log_remove_request(**kwargs) if ('add_request' in exc.errors()[0]["loc"][1]): return log_add_request(*args, **kwargs) else: return log_incoming_request(*args, **kwargs) def log_add_request(task: str, host: str, loggers: dict, request_id: str = None, body: dict = None, error_msg: str = None) -> None: """ Send log messages according to type of event. :param task: `str` type of event to log, can be 'log_request', 'log_success' or 'log_failure' :param host: `str` client host where request comes from :param loggers: `dict` global logging instances to write messages (params.loggers) :param request_id: `str` request ID generated on request :param body: `dict` body of request :param error_msg: `str` to show in log """ if task == 'log_request': payload = fix_payload(body) level = 'info' msg = f'{host} - Add Token - {request_id}: ' \ f'{payload}' log_event(msg=msg, level=level, loggers=loggers) elif task == 'log_success': payload = fix_payload(body) msg = f'{host} - Add Token - Response {request_id}: ' \ f'{payload}' level = 'info' log_event(msg=msg, level=level, loggers=loggers) elif task == 'log_failure': level = 'error' resp = error_msg print(resp) msg = f'{host} - Add Token Failed - Response {request_id}: ' \ f'{resp}' log_event(loggers=loggers, msg=msg, level=level) def log_remove_request(task: str, host: str, loggers: dict, request_id: str = None, body: dict = None, error_msg: str = None) -> None: """ Send log messages according to type of event. :param task: `str` type of event to log, can be 'log_request', 'log_success' or 'log_failure' :param host: `str` client host where request comes from :param loggers: `dict` global logging instances to write messages (params.loggers) :param request_id: `str` request ID generated on request :param body: `dict` body of request :param error_msg: `str` to show in log """ if task == 'log_request': payload = fix_payload(body) level = 'info' msg = f'{host} - Remove Token - {request_id}: ' \ f'{payload}' log_event(msg=msg, level=level, loggers=loggers) elif task == 'log_success': payload = fix_payload(body) msg = f'{host} - Remove Token - Response {request_id}: ' \ f'{payload}' level = 'info' log_event(msg=msg, level=level, loggers=loggers) elif task == 'log_failure': level = 'error' resp = error_msg msg = f'{host} - Remove Token Failed - Response {request_id}: ' \ f'{resp}' log_event(loggers=loggers, msg=msg, level=level) def log_push_request(task: str, host: str, loggers: dict, request_id: str = None, body: dict = None, error_msg: str = None) -> None: """ Send log messages according to type of event. :param task: `str` type of event to log, can be 'log_request', 'log_success' or 'log_failure' :param host: `str` client host where request comes from :param loggers: `dict` global logging instances to write messages (params.loggers) :param request_id: `str` request ID generated on request :param body: `dict` body of request :param error_msg: `str` to show in log """ sip_to = body.get('sip_to') event = body.get('event') if task == 'log_request': payload = fix_payload(body) level = 'info' msg = f'{host} - Push - {request_id}: ' \ f'{event} for {sip_to} ' \ f': {payload}' log_event(msg=msg, level=level, loggers=loggers) elif task == 'log_failure': level = 'error' resp = error_msg msg = f'{host} - Push Failed - Response {request_id}: ' \ f'{resp}' log_event(loggers=loggers, msg=msg, level=level) def log_incoming_request(task: str, host: str, loggers: dict, request_id: str = None, body: dict = None, error_msg: str = None) -> None: """ Send log messages according to type of event. :param task: `str` type of event to log, can be 'log_request', 'log_success' or 'log_failure' :param host: `str` client host where request comes from :param loggers: `dict` global logging instances to write messages (params.loggers) :param request_id: `str` request ID generated on request :param body: `dict` body of request :param error_msg: `str` to show in log """ app_id = body.get('app_id') platform = body.get('platform') platform = platform if platform else '' sip_to = body.get('sip_to') device_id = body.get('device_id') device_id = fix_device_id(device_id) if device_id else None event = body.get('event') if task == 'log_request': payload = fix_payload(body) level = 'info' if sip_to: if device_id: msg = f'incoming {platform.title()} request {request_id}: ' \ f'{event} for {sip_to} using' \ f' device {device_id} from {host}: {payload}' else: msg = f'incoming {platform.title()} request {request_id}: ' \ f'{event} for {sip_to} ' \ f'from {host}: {payload}' elif device_id: msg = f'incoming {platform.title()} request {request_id}: ' \ f'{event} using' \ f' device {device_id} from {host}: {payload}' else: msg = f'incoming {platform.title()} request {request_id}: ' \ f' from {host}: {payload}' log_event(msg=msg, level=level, loggers=loggers) elif task == 'log_success': msg = f'incoming {platform.title()} response for {request_id}: ' \ f'push accepted' level = 'info' log_event(msg=msg, level=level, loggers=loggers) elif task == 'log_failure': level = 'error' resp = error_msg msg = f'incoming {platform.title()} from {host} response for {request_id}, ' \ f'push rejected: {resp}' log_event(loggers=loggers, msg=msg, level=level)