diff --git a/pushserver/models/requests.py b/pushserver/models/requests.py index 55e3f60..53d0e42 100644 --- a/pushserver/models/requests.py +++ b/pushserver/models/requests.py @@ -1,223 +1,223 @@ from pydantic import BaseModel, root_validator, validator from pushserver.resources import settings from pushserver.resources.utils import fix_platform_name def gen_validator_items() -> tuple: """ Generate some dicts according to minimum required parameters, and each app required paramaters, usefull for request validation. :return: two `dict` objects with common items and apps items. """ common_items = {'app-id', 'call-id', 'platform', 'from', 'token'} only_sylk_items = {'silent', 'to', 'event'} only_linphone_items = set() apps_items = {'sylk': common_items | only_sylk_items, # union 'linphone': common_items | only_linphone_items} return common_items, apps_items common_items, apps_items = gen_validator_items() def alias_rename(attribute: str) -> str: """ Rename request name attribute, replacing '_' by '_' and removing 'sip_' characters. :param attribute: `str` from request :return: a `str` corresponding to alias. """ if attribute.startswith('sip_'): return attribute.split('_', maxsplit=1)[1] return attribute.replace('_', '-') class AddRequest(BaseModel): app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' token: str # destination device token in hex device_id: str # the device-id that owns the token (used for logging purposes) silent: bool = True user_agent: str = None class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_for_add(cls, values): app_id, platform = values.get('app-id'), values.get('platform') if not app_id: raise ValueError("Field 'app-id' required") if not platform: raise ValueError("Field 'platform' required") platform = fix_platform_name(platform) if platform not in ('firebase', 'apple'): raise ValueError(f"The '{platform}' platform is not configured") pns_register = settings.params.pns_register if (app_id, platform) not in pns_register.keys(): raise ValueError(f"{platform.capitalize()} {app_id} app " f"is not configured") return values @validator('platform') def platform_valid_values(cls, v): - if v not in ('apple', 'ios', 'android', 'firebase', 'fcm'): + if v not in ('apple', 'ios', 'android', 'firebase', 'fcm', 'apns'): raise ValueError("platform must be 'apple', 'android' or 'firebase'") return v class AddResponse(BaseModel): app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' token: str # destination device token in hex device_id: str # the device-id that owns the token (used for logging purposes) silent: bool = True user_agent: str = None class Config: allow_population_by_field_name = True alias_generator = alias_rename class RemoveRequest(BaseModel): app_id: str # id provided by the mobile application (bundle id) device_id: str = None # the device-id that owns the token (used for logging purposes) class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_for_add(cls, values): app_id = values.get('app-id') if not app_id: raise ValueError("Field 'app-id' required") return values class RemoveResponse(BaseModel): app_id: str # id provided by the mobile application (bundle id) device_id: str = None # the device-id that owns the token (used for logging purposes) class Config: allow_population_by_field_name = True alias_generator = alias_rename class PushRequest(BaseModel): event: str = None # (required for sylk) 'incoming_session', 'incoming_conference' or 'cancel' call_id: str # (required for apple) unique sip parameter sip_from: str # (required for firebase) SIP URI for who is calling from_display_name: str = None # (required for sylk) display name of the caller to: str # SIP URI for who is called media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' reason: str = None # Cancel reason badge: int = 1 class Config: alias_generator = alias_rename class WakeUpRequest(BaseModel): # API expects a json object like: app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' event: str = None # (required for sylk) 'incoming_session', 'incoming_conference', 'cancel' or 'message' token: str # destination device token in hex device_id: str = None # the device-id that owns the token (used for logging purposes) call_id: str # (required for apple) unique sip parameter sip_from: str # (required for firebase) SIP URI for who is calling from_display_name: str = None # (required for sylk) display name of the caller sip_to: str # SIP URI for who is called media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' silent: bool = True # True for silent notification reason: str = None # Cancel reason badge: int = 1 class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_by_app(cls, values): app_id, platform = values.get('app-id'), values.get('platform') if not app_id: raise ValueError("Field 'app-id' required") if not platform: raise ValueError("Field 'platform' required") platform = fix_platform_name(platform) if platform not in ('firebase', 'apple'): raise ValueError(f"'{platform}' platform is not configured") pns_register = settings.params.pns_register if (app_id, platform) not in pns_register.keys(): raise ValueError(f"{platform.capitalize()} {app_id} app " f"is not configured") try: name = pns_register[(app_id, platform)]['name'] check_items = apps_items[name] missing_items = [] for item in check_items: if values.get(item) is None: missing_items.append(item) if missing_items: missing_items_show = [] for item in missing_items: if item in ('sip_to', 'sip_from', 'device_id'): item = item.split('_')[1] else: item = item.replace('-', '_') missing_items_show.append(item) raise ValueError(f"'{' ,'.join(missing_items)}' " f"item(s) missing.") except KeyError: pass event = values.get('event') if event != 'cancel': media_type = values.get('media-type') if not media_type: raise ValueError("Field media-type required") if media_type not in ('audio', 'video', 'chat', 'sms', 'file-transfer'): raise ValueError("media-type must be 'audio', 'video', " "'chat', 'sms', 'file-transfer'") if 'linphone' in name: if event: if event != 'incoming_session': raise ValueError('event not found (must be incoming_sesion)') else: values['event'] = 'incoming_session' return values @validator('platform') def platform_valid_values(cls, v): if v not in ('apple', 'ios', 'android', 'firebase'): raise ValueError("platform must be 'apple', 'android' or 'firebase'") return v @validator('event') def event_valid_values(cls, v): if v not in ('incoming_session', 'incoming_conference_request', 'cancel', 'message'): raise ValueError("event must be 'incoming_session', 'incoming_conference_request', 'cancel' or 'message'") return v diff --git a/pushserver/resources/utils.py b/pushserver/resources/utils.py index 247f41d..0453140 100644 --- a/pushserver/resources/utils.py +++ b/pushserver/resources/utils.py @@ -1,377 +1,377 @@ import hashlib import json import logging import socket import ssl import time from ipaddress import ip_address __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) elif logger.level != logging.INFO and loggers['debug'] is False: logger.setLevel(logging.INFO) 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', 'fcm'): return 'firebase' - elif platform in ('apple', 'ios'): + elif platform in ('apple', 'ios', 'apns'): 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 [{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 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 [{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('to') event = body.get('event') if task == 'log_request': payload = fix_payload(body) level = 'info' msg = f'{host} - Push - Request [{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)