├── debian ├── compat ├── docs ├── source │ ├── lintian-overrides │ └── format ├── sylk-pushserver.examples ├── dirs ├── sylk-pushserver.install ├── logrotate ├── sylk-pushserver.service ├── sylk-pushserver.links ├── copyright ├── rules ├── control └── changelog ├── config ├── pns │ ├── __init__.py │ └── mypns.py ├── applications │ ├── __init__.py │ └── myapp.py ├── general.ini.sample ├── applications.ini.sample └── opensips.cfg ├── pushserver ├── __init__.py ├── api │ ├── __init__.py │ ├── errors │ │ ├── __init__.py │ │ └── validation_error.py │ └── routes │ │ ├── __init__.py │ │ ├── v2 │ │ ├── __init__.py │ │ ├── add.py │ │ ├── remove.py │ │ └── push.py │ │ ├── home.py │ │ ├── api.py │ │ └── push.py ├── models │ ├── __init__.py │ ├── cassandra.py │ └── requests.py ├── pns │ ├── __init__.py │ ├── base.py │ ├── register.py │ └── firebase.py ├── resources │ ├── storage │ │ ├── __init__.py │ │ ├── errors.py │ │ ├── configuration.py │ │ └── storage.py │ ├── __init__.py │ ├── notification.py │ ├── server.py │ ├── settings.py │ └── utils.py ├── applications │ ├── __init__.py │ ├── linphone.py │ ├── firebase.py │ ├── apple.py │ └── sylk.py └── __info__.py ├── debian-requirements.txt ├── makedeb.sh ├── requirements.txt ├── debian-packaging-notes.md ├── LICENSE ├── MANIFEST.in ├── setup.py ├── scripts ├── sylk-pushclient ├── sylk-pushclient-v2 └── sylk-pushserver-db ├── sylk-pushserver └── README.md /debian/compat: -------------------------------------------------------------------------------- 1 | 11 -------------------------------------------------------------------------------- /config/pns/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pushserver/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/docs: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /pushserver/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/applications/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/source/lintian-overrides: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /pushserver/api/errors/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['validation_error'] -------------------------------------------------------------------------------- /pushserver/models/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['cassandra', 'requests'] 2 | -------------------------------------------------------------------------------- /pushserver/api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['api', 'home', 'push', 'v2'] 2 | -------------------------------------------------------------------------------- /pushserver/api/routes/v2/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['push', 'add', 'remove'] 2 | -------------------------------------------------------------------------------- /pushserver/pns/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['apple', 'base', 'firebase', 'register'] -------------------------------------------------------------------------------- /pushserver/resources/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .storage import TokenStorage 2 | -------------------------------------------------------------------------------- /pushserver/resources/storage/errors.py: -------------------------------------------------------------------------------- 1 | class StorageError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /pushserver/applications/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['apple', 'firebase', 'linphone', 'sylk'] 2 | -------------------------------------------------------------------------------- /pushserver/resources/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['notification', 'pns', 'settings', 'utils', 'storage'] 2 | -------------------------------------------------------------------------------- /debian/sylk-pushserver.examples: -------------------------------------------------------------------------------- 1 | config/applications.ini.sample 2 | config/general.ini.sample 3 | config/opensips.cfg 4 | config/pns 5 | config/applications 6 | 7 | -------------------------------------------------------------------------------- /debian/dirs: -------------------------------------------------------------------------------- 1 | /etc/sylk-pushserver/ 2 | /etc/sylk-pushserver/applications 3 | /etc/sylk-pushserver/pns 4 | /etc/sylk-pushserver/credentials 5 | /usr/lib 6 | /var/log/sylk-pushserver 7 | -------------------------------------------------------------------------------- /debian/sylk-pushserver.install: -------------------------------------------------------------------------------- 1 | config/*.ini.sample etc/sylk-pushserver/ 2 | config/applications/*.py /etc/sylk-pushserver/applications/ 3 | config/pns/*.py /etc/sylk-pushserver/pns/ 4 | -------------------------------------------------------------------------------- /debian/logrotate: -------------------------------------------------------------------------------- 1 | /var/log/sylk-pushserver/*.log 2 | { 3 | rotate 7 4 | daily 5 | delaycompress 6 | compress 7 | missingok 8 | copytruncate 9 | nocreate 10 | } 11 | 12 | -------------------------------------------------------------------------------- /pushserver/api/routes/home.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | router = APIRouter() 4 | 5 | 6 | @router.get('/') 7 | async def welcome(): 8 | return 'Welcome to sylk-push server' 9 | -------------------------------------------------------------------------------- /debian-requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi == 0.52.0 2 | httpx[http2] >= 0.21.1 3 | pydantic >= 1.4 4 | uvicorn >= 0.11.3 5 | websockets >= 10.4 6 | # needed for buster/focal/bullseye 7 | #pyjwt[crypto] >= 2.8.0 8 | #cryptography<39 9 | #pyOpenSSL==22.0.0 10 | -------------------------------------------------------------------------------- /makedeb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ -f dist ]; then 3 | rm -r dist 4 | fi 5 | 6 | python3 setup.py sdist 7 | sleep 2 8 | 9 | cd dist 10 | 11 | tar zxvf *.tar.gz 12 | cd sylk_pushserver-?.?.? 13 | ls 14 | sleep 3 15 | 16 | debuild --no-sign 17 | 18 | ls 19 | -------------------------------------------------------------------------------- /debian/sylk-pushserver.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Sylk Push Notifications server 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/sylk-pushserver --config_dir /etc/sylk-pushserver/ 7 | Restart=always 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi == 0.52.0 2 | httpx[http2] >= 0.21.1 3 | oauth2client >= 4.1.3 4 | pydantic >= 1.4 5 | pyinotify >= 0.9.6 6 | requests >= 2.23.0 7 | cysystemd >= 1.5.3 8 | uvicorn >= 0.11.8 9 | #firebase-admin 10 | cassandra-driver == 3.29.2 11 | python3-application @ git+https://github.com/AGProjects/python3-application@release-3.0.9 12 | PyJWT[crypto] >= 2.10.1 13 | -------------------------------------------------------------------------------- /debian-packaging-notes.md: -------------------------------------------------------------------------------- 1 | For older systems (buster/bullseye and ubuntu focal) uncomment the packages in debian-requirements and comment python3-jwt in debian/control before running debuild. 2 | 3 | For newer then bookworm (ubuntu noble/debian sid) comment upgrade pip line in rules. 4 | 5 | TODO: 6 | 7 | Rework it. Virtual env may not be needed in newer Debian/Ubuntu 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /pushserver/__info__.py: -------------------------------------------------------------------------------- 1 | """Package information""" 2 | 3 | __version__ = '2.1.0' 4 | __project__ = 'sylk_pushserver' 5 | __summary__ = 'Mobile push notifications for RTC infrastructures' 6 | __webpage__ = 'https://sylkserver.com' 7 | __author__ = 'Bibiana Rivadeneira' 8 | __email__ = 'support@ag-projects.com' 9 | __license__ = 'GPL v3' 10 | __copyright__ = 'Copyright 2025 AG Projects' 11 | -------------------------------------------------------------------------------- /debian/sylk-pushserver.links: -------------------------------------------------------------------------------- 1 | usr/lib/python3/dist-packages/sylk-pushserver/bin/sylk-pushserver usr/bin/sylk-pushserver 2 | usr/lib/python3/dist-packages/sylk-pushserver/bin/sylk-pushclient usr/bin/sylk-pushclient 3 | usr/lib/python3/dist-packages/sylk-pushserver/bin/sylk-pushclient-v2 usr/bin/sylk-pushclient-v2 4 | usr/lib/python3/dist-packages/sylk-pushserver/bin/sylk-pushserver-db usr/bin/sylk-pushserver-db 5 | -------------------------------------------------------------------------------- /pushserver/api/routes/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from pushserver.api.routes import home, push 4 | from pushserver.api.routes.v2 import add, push as push_v2, remove 5 | 6 | 7 | router = APIRouter() 8 | router.include_router(home.router, tags=["welcome", "home"]) 9 | router.include_router(push.router, tags=["push"], prefix="/push") 10 | 11 | router.include_router(add.router, tags=["v2"], prefix="/v2/tokens") 12 | router.include_router(push_v2.router, tags=["v2"], prefix="/v2/tokens") 13 | router.include_router(remove.router, tags=["v2"], prefix="/v2/tokens") 14 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 AG Projects (http://ag-projects.com) 2 | 3 | License: GPL-3+ 4 | 5 | This program is free software; you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation; either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | For a copy of the license see https://www.gnu.org/licenses/gpl.html 16 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Copyright 2020 AG Projects (http://ag-projects.com) 2 | 3 | License: GPL-3+ 4 | 5 | This program is free software; you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation; either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | For a copy of the license see /usr/share/common-licenses/GPL-3 16 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include MANIFEST.in 4 | 5 | include __info__.py 6 | recursive-include config *.sample 7 | recursive-include config *.py 8 | include config/opensips.cfg 9 | 10 | include scripts/sylk-pushclient 11 | include debian-requirements.txt 12 | include requirements.txt 13 | 14 | include debian/changelog 15 | include debian/compat 16 | include debian/control 17 | include debian/copyright 18 | include debian/rules 19 | include debian/docs 20 | include debian/dirs 21 | include debian/source/format 22 | include debian/source/lintian-overrides 23 | include debian/*links 24 | include debian/*install 25 | include debian/*logrotate 26 | include debian/*service 27 | include debian/*examples 28 | 29 | include *.py 30 | recursive-include pushserver *.py -------------------------------------------------------------------------------- /config/pns/mypns.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | # To create a new app, import base classes from: 4 | 5 | # this app is based on existing apple pns 6 | # from pushserver.pns.apple import * 7 | 8 | # this app is based on existing firebase pns 9 | # from pushserver.pns.firebase import * 10 | 11 | # this app is based on the base pns 12 | from pushserver.pns.base import * 13 | 14 | 15 | __all__ = ['MyPnsPNS', 'MyPnsRegister'] 16 | 17 | 18 | class MyPnsPNS(PNS): 19 | """ 20 | A Push Notification service 21 | """ 22 | 23 | 24 | class MyPnsRegister(PlatformRegister): 25 | """ 26 | A register with pns and other needed objects 27 | 28 | 29 | @property 30 | def register_entries(self): 31 | 32 | return {'pns': self.pns, 33 | ..., 34 | } 35 | """ -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | export DH_VERBOSE = 1 4 | export SETUPTOOLS_DEB_LAYOUT = 1 5 | export DH_VIRTUALENV_INSTALL_ROOT = /usr/lib/python3/dist-packages 6 | export DH_VIRTUALENV_ARGUMENTS := --always-copy --python python3 --system-site-packages 7 | export DH_REQUIREMENTS_FILE := debian-requirements.txt 8 | export DH_UPGRADE_PIP := 21.2.4 9 | 10 | %: 11 | dh $@ --buildsystem dh_virtualenv 12 | 13 | 14 | override_dh_python3: 15 | dh_python3 --shebang=/usr/lib/python3/dist-packages/sylk-pushserver/bin/python3 16 | 17 | override_dh_clean: 18 | dh_clean 19 | rm -rf build dist MANIFEST scripts-3.* bdist.linux-x86_64 20 | 21 | override_dh_install: 22 | dh_install 23 | find debian/sylk-pushserver -type d -name 'scripts-3.*' -exec rm -rf {} + 24 | find debian/sylk-pushserver -type d -name 'bdist.linux-x86_64' -exec rm -rf {} + 25 | py3clean . 26 | 27 | override_dh_installsystemd: 28 | dh_installsystemd -psylk-pushserver --name=sylk-pushserver --no-start 29 | 30 | override_dh_auto_test: 31 | -------------------------------------------------------------------------------- /pushserver/models/cassandra.py: -------------------------------------------------------------------------------- 1 | from cassandra.cqlengine import columns 2 | from cassandra.cqlengine.models import Model 3 | 4 | 5 | class PushTokens(Model): 6 | __table_name__ = 'push_tokens' 7 | username = columns.Text(partition_key=True) 8 | domain = columns.Text(partition_key=True) 9 | device_id = columns.Text(primary_key=True) 10 | app_id = columns.Text(primary_key=True) 11 | background_token = columns.Text(required=False) 12 | device_token = columns.Text() 13 | platform = columns.Text() 14 | silent = columns.Text() 15 | user_agent = columns.Text(required=False) 16 | 17 | 18 | class OpenSips(Model): 19 | """Useful for servers that need to take routing 20 | decisions based on the fact that the user has push 21 | tokens, without having to send push notifications 22 | """ 23 | 24 | __table_name__ = 'mobile_devices' 25 | opensipskey = columns.Text(primary_key=True) 26 | opensipsval = columns.Text() 27 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: sylk-pushserver 2 | Maintainer: AG Projects 3 | Uploaders: Adrian Georgescu , Tijmen de Mes 4 | Section: net 5 | Priority: optional 6 | Standards-Version: 4.3.0 7 | Build-Depends: debhelper (>= 11~), 8 | python3 (>= 3.7), 9 | dh-virtualenv (>= 1.0), 10 | python3-setuptools, 11 | python3-pip, 12 | python3-dev, 13 | rename 14 | 15 | X-Python3-Version: >= 3.7 16 | Package: sylk-pushserver 17 | Architecture: any 18 | Section: net 19 | Depends: ${misc:Depends}, ${shlibs:Depends}, sensible-utils, 20 | python3 (>= 3.7), 21 | python3-oauth2client (>= 4.1.2), 22 | python3-pyinotify (>= 0.9.6), 23 | python3-requests (>= 2.21), 24 | python3-systemd (>= 0.16.1), 25 | python3-application (>=3.0.0), 26 | python3-click (>= 7.0), 27 | python3-uvloop (>= 0.11.2), 28 | python3-httptools (>= 0.0.11), 29 | python3-jwt, 30 | python3-cassandra | python-cassandra 31 | Enhances: sylkserver-webrtc-gateway 32 | Description: Mobile push notifications for RTC infrastructures 33 | Sylk Pushserver was designed to act as a central dispatcher for mobile push 34 | notifications inside RTC provider infrastructures. Both the provider and 35 | the mobile application customer, in the case of a shared infrastructure, 36 | can easily audit problems related to the processing of push notifications. 37 | -------------------------------------------------------------------------------- /pushserver/resources/storage/configuration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from application.configuration import ConfigSection, ConfigSetting 5 | from application.configuration.datatypes import HostnameList 6 | from application.python.descriptor import classproperty 7 | 8 | 9 | __all__ = 'CassandraConfig', 'ServerConfig' 10 | 11 | 12 | class Path(str): 13 | def __new__(cls, path): 14 | if path: 15 | path = os.path.normpath(path) 16 | return str.__new__(cls, path) 17 | 18 | @property 19 | def normalized(self): 20 | return os.path.expanduser(self) 21 | 22 | 23 | class VarResources(object): 24 | """Provide access to Sylk-Pushserver's resources that should go in /var""" 25 | 26 | _cached_directory = None 27 | 28 | @classproperty 29 | def directory(cls): 30 | if cls._cached_directory is None: 31 | binary_directory = os.path.dirname(os.path.realpath(sys.argv[0])) 32 | if os.path.basename(binary_directory) == 'bin': 33 | path = '/var' 34 | else: 35 | path = 'var' 36 | cls._cached_directory = os.path.abspath(path) 37 | return cls._cached_directory 38 | 39 | @classmethod 40 | def get(cls, resource): 41 | return os.path.join(cls.directory, resource or u'') 42 | 43 | 44 | class CassandraConfig(ConfigSection): 45 | __cfgfile__ = 'general.ini' 46 | __section__ = 'Cassandra' 47 | 48 | cluster_contact_points = ConfigSetting(type=HostnameList, value=None) 49 | keyspace = ConfigSetting(type=str, value='') 50 | table = ConfigSetting(type=str, value='') 51 | debug = False 52 | 53 | class ServerConfig(ConfigSection): 54 | __cfgfile__ = 'general.ini' 55 | __section__ = 'server' 56 | 57 | spool_dir = ConfigSetting(type=Path, value=Path(VarResources.get('spool/sylk-pushserver'))) 58 | -------------------------------------------------------------------------------- /config/applications/myapp.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | # To create a new app, import base classes from: 4 | 5 | # this app is based on existing sylk app 6 | # import json 7 | # from pushserver.applications.sylk import * 8 | 9 | # this app is based on existing linphone app 10 | # import json 11 | # from pushserver.applications.linphone import * 12 | 13 | # this app is based on the base app 14 | import json 15 | from pushserver.applications.apple import * 16 | from pushserver.applications.firebase import * 17 | 18 | 19 | __all__ = ['AppleMyappHeaders', 'AppleMyappPayload', 20 | 'FirebaseMyappHeaders', 'FirebaseMyappPayload'] 21 | 22 | 23 | class AppleMyappHeaders(AppleHeaders): 24 | """ 25 | An Apple headers structure for a push notification 26 | 27 | 28 | @property 29 | def headers(self): 30 | """ 31 | # Return: a valid dict of headers 32 | """ 33 | data = {} 34 | headers = json.dumps(data) 35 | return headers 36 | """ 37 | 38 | 39 | class AppleMyappPayload(ApplePayload): 40 | """ 41 | An Apple headers structure for a push notification 42 | 43 | @property 44 | def payload(self) -> dict: 45 | """ 46 | # Return a valid payload: 47 | """ 48 | data = {} 49 | payload = json.dumps(data) 50 | return payload 51 | """ 52 | 53 | 54 | class FirebaseMyappHeaders(FirebaseHeaders): 55 | """ 56 | Firebase headers for a push notification 57 | 58 | 59 | @property 60 | def headers(self): 61 | """ 62 | # Return: a valid dict of headers 63 | """ 64 | data = {} 65 | headers = json.dumps(data) 66 | return headers 67 | """ 68 | 69 | 70 | class FirebaseMyAppPayload(FirebasePayload): 71 | """ 72 | A payload for a Firebase push notification 73 | 74 | @property 75 | def payload(self) -> dict: 76 | """ 77 | # Return a valid payload: 78 | """ 79 | data = {} 80 | payload = json.dumps(data) 81 | return payload 82 | """ -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import glob 3 | import os 4 | 5 | from setuptools import setup 6 | 7 | import pushserver.__info__ as package_info 8 | 9 | long_description = """ 10 | Sylk Pushserver was designed to act as a central dispatcher for mobile push 11 | notifications inside RTC provider infrastructures. Both the provider and 12 | the mobile application customer, in the case of a shared infrastructure, can 13 | easily audit problems related to the processing of push notifications. 14 | """ 15 | 16 | def find_packages(root): 17 | return [directory.replace(os.path.sep, '.') for directory, sub_dirs, files in os.walk(root) if '__init__.py' in files] 18 | 19 | def requirements(): 20 | install_requires = [] 21 | with open('requirements.txt') as f: 22 | for line in f: 23 | install_requires.append(line.strip()) 24 | return install_requires 25 | 26 | 27 | setup(name=package_info.__project__, 28 | version=package_info.__version__, 29 | description=package_info.__summary__, 30 | long_description=long_description, 31 | author=package_info.__author__, 32 | license=package_info.__license__, 33 | platforms=['Platform Independent'], 34 | author_email=package_info.__email__, 35 | url=package_info.__webpage__, 36 | scripts=['sylk-pushserver', 'scripts/sylk-pushclient', 'scripts/sylk-pushclient-v2', 'scripts/sylk-pushserver-db'], 37 | packages=find_packages('pushserver'), 38 | # install_requires=requirements(), 39 | classifiers=[ 40 | 'Development Status :: 5 - Production/Stable', 41 | 'Intended Audience :: Service Providers', 42 | 'License :: GPL v3', 43 | 'Operating System :: OS Independent', 44 | 'Programming Language :: Python', 45 | ], 46 | data_files=[('/etc/sylk-pushserver', []), 47 | ('/etc/sylk-pushserver', glob.glob('config/*.sample')), 48 | ('/etc/sylk-pushserver/credentials', []), 49 | ('/etc/sylk-pushserver/applications', 50 | glob.glob('config/applications/*.py')), 51 | ('/etc/sylk-pushserver/applications/app_template', 52 | glob.glob('config/applications/app_template/*.py')), 53 | ('/var/log/sylk-pushserver', [])] 54 | ) 55 | -------------------------------------------------------------------------------- /pushserver/api/errors/validation_error.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi import Request, status 4 | from fastapi.encoders import jsonable_encoder 5 | from fastapi.exceptions import RequestValidationError 6 | from fastapi.responses import JSONResponse 7 | from pydantic import ValidationError 8 | 9 | from pushserver.resources import settings 10 | from pushserver.resources.utils import pick_log_function 11 | 12 | 13 | async def validation_exception_handler(request: Request, 14 | exc: Union[RequestValidationError, 15 | ValidationError]) -> JSONResponse: 16 | host, port = request.scope['client'][0], request.scope['client'][1] 17 | account = None 18 | error_msg = None 19 | code = 400 20 | status_code = status.HTTP_400_BAD_REQUEST 21 | 22 | for entry in exc.errors(): 23 | if 'found' in entry['msg'] or 'configured' in entry['msg']: 24 | status_code = status.HTTP_404_NOT_FOUND 25 | code = 404 26 | 27 | if not error_msg: 28 | if '__root__' not in exc.errors()[0]["loc"][2]: 29 | error_msg = f'{exc.errors()[0]["msg"]}: {exc.errors()[0]["loc"][2]}' 30 | else: 31 | error_msg = exc.errors()[0]["msg"] 32 | 33 | try: 34 | request_id = f"{exc.body['event']} - " \ 35 | f"{exc.body['app-id']}-" \ 36 | f"{exc.body['call-id']}" 37 | except (KeyError, TypeError): 38 | try: 39 | account = request['path_params']['account'] 40 | request_id = f"{account}-" \ 41 | f"{exc.body['app-id']}-" \ 42 | f"{exc.body['device-id']}" 43 | except (KeyError, TypeError): 44 | request_id = "unknown" 45 | 46 | pick_log_function(exc, task='log_request', host=host, 47 | loggers=settings.params.loggers, 48 | request_id=request_id, body=exc.body) 49 | 50 | pick_log_function(exc, task='log_failure', host=host, 51 | loggers=settings.params.loggers, 52 | request_id=request_id, body=exc.body, 53 | error_msg=error_msg) 54 | 55 | content = jsonable_encoder({'code': code, 56 | 'description': error_msg, 57 | 'data': ''}) 58 | 59 | return JSONResponse( 60 | status_code=status_code, 61 | content=content) 62 | -------------------------------------------------------------------------------- /config/general.ini.sample: -------------------------------------------------------------------------------- 1 | ; The values after the ; are the default values, uncomment them only if you 2 | ; want to make changes 3 | 4 | [server] 5 | ; host = 0.0.0.0 6 | ; port = 8400 7 | 8 | ; The file containing X.509 certificate and private key in unencrypted format 9 | ; If a certificate is set, the server will listen using TLS 10 | ; tls_certificate = '' 11 | 12 | ; by default the server will respond to the client after the outgoing 13 | ; request for the push notification is completed. If false, the server will 14 | ; reply imediately with 202. The result of the push notification can then 15 | ; be found only in the logs. This is designed for client that can block and 16 | ; cannot or do not want to wait for the push operation to be completed 17 | ; return_async = true 18 | 19 | ; by default any client is allowed to send requests to the server 20 | ; IP addresses and networks in CIDR notation are supported 21 | ; e.g: 10.10.10.0/24, 127.0.0.1, 192.168.1.2 22 | ; allowed_hosts = [] 23 | 24 | ; by default logs go to the journal; uncomment below to also log to a file 25 | ; log_to_file = true 26 | ; log_file = /var/log/sylk-pushserver/push.log 27 | 28 | ; Base directory for files created by the token storage 29 | ; spool_dir = /var/spool/sylk-pushserver 30 | 31 | ; If debug is true, headers and payloads for the outgoing requests will also 32 | ; be logged 33 | ; debug = False 34 | 35 | ; Turn on Hpack debugging for Apple connections, normal debug needs to be also 36 | ; enabled 37 | ; debug_hpack = False 38 | 39 | [applications] 40 | ; paths are relative to the config directory, by default /etc/sylk-pushserver 41 | ; and if missing ./config from the curent directory 42 | 43 | ; mobile applications are configured in this file 44 | ; config_file = applications.ini 45 | 46 | ; credentials relative paths are relative to this directory 47 | ; credentials_folder = credentials 48 | 49 | ; more applications can be added to this directory 50 | ; extra_applications_dir = applications/ 51 | 52 | ; more pns can be added to this directory 53 | ; extra_pns_dir = pns/ 54 | 55 | 56 | [Cassandra] 57 | ; configuration for token storage to use a Cassandra cluster 58 | ; if nothing is set here it will use a pickle file to store the tokens if 59 | ; API version 2 is used 60 | 61 | ; Contact points to cassandra cluster 62 | ; cluster_contact_points = 63 | 64 | ; Keyspace to use to retrieve tokens 65 | ; keyspace = 66 | 67 | ; Table to use to store tokens, default it will use push_tokens 68 | ; table = 69 | 70 | ; Debug cassandra 71 | ; debug = false 72 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | sylk-pushserver (2.1.0) unstable; urgency=medium 2 | 3 | * Added support for a token based connection to APNs 4 | * Added more meaninfull error on cert error 5 | * Added extra data items to sylk message payload on IOS 6 | * Fixed scripts 7 | * Updated docs 8 | * Updated dependencies 9 | * Correct typo 10 | 11 | -- Tijmen de Mes Fri, 13 Jun 2025 14:23:45 +0200 12 | 13 | sylk-pushserver (2.0.1) unstable; urgency=medium 14 | 15 | * Fixed removing expired devices when using API v2 16 | * Updated debian dependencies 17 | 18 | -- Tijmen de Mes Thu, 13 Apr 2023 13:13:58 +0200 19 | 20 | sylk-pushserver (2.0.0) unstable; urgency=medium 21 | 22 | * Added support for file transfers 23 | * Added support for messaging 24 | * Added mobile tokens storage 25 | * Added v2 routes for storage API 26 | * Support 'fcm' as platform name 27 | * Support 'apns' as platform name 28 | * Fixed debian packaging 29 | * Cleaned up log functions 30 | * Changed shebang so it runs in a virtual env 31 | * Refactored configuration loading 32 | * Use payload fix function 33 | * Return more usefull errors if request validation fails 34 | * Added pushclient to use v2 API 35 | 36 | -- Tijmen de Mes Thu, 12 Jan 2023 10:01:09 +0100 37 | 38 | sylk-pushserver (1.0.7) unstable; urgency=medium 39 | 40 | * Purging firebase token on 404 41 | * Debian package fixes 42 | 43 | -- Adrian Georgescu Mon, 05 Oct 2020 16:41:57 +0200 44 | 45 | sylk-pushserver (1.0.6) unstable; urgency=medium 46 | 47 | * Fixed purging old tokens 48 | 49 | -- Adrian Georgescu Sat, 19 Sep 2020 14:01:12 +0200 50 | 51 | sylk-pushserver (1.0.5) unstable; urgency=medium 52 | 53 | * Added cancel reason 54 | * Always refresh Firebase access token before sending 55 | 56 | -- Adrian Georgescu Sat, 22 Aug 2020 15:52:04 +0200 57 | 58 | sylk-pushserver (1.0.4) unstable; urgency=medium 59 | 60 | * Always retry immediately with new token if Firebase returned 401 61 | * Remove duplicate logging 62 | 63 | -- Adrian Georgescu Tue, 18 Aug 2020 14:29:05 +0200 64 | 65 | sylk-pushserver (1.0.3) unstable; urgency=medium 66 | 67 | * Detect firebase expired token based on 404 code 68 | 69 | -- Adrian Georgescu Mon, 17 Aug 2020 13:08:10 +0200 70 | 71 | sylk-pushserver (1.0.2) unstable; urgency=medium 72 | 73 | * Firebase token fixes 74 | * Fix converting call-id to uuidv4 for sylk app 75 | * Switch Sylk conference invite push type to VoIP 76 | 77 | -- Adrian Georgescu Wed, 01 Jul 2020 19:10:44 +0200 78 | 79 | sylk-pushserver (1.0.1) unstable; urgency=medium 80 | 81 | * Added software sources location 82 | * Added content-available header to Sylk payload 83 | * Handle Firebase 401 exception 84 | 85 | -- Adrian Georgescu Mon, 22 Jun 2020 21:13:34 +0200 86 | 87 | sylk-pushserver (1.0.0) unstable; urgency=low 88 | 89 | * First version of sylk-pushserver 90 | 91 | -- Adrian Georgescu Fri, 29 May 2020 15:16:07 +0200 92 | -------------------------------------------------------------------------------- /pushserver/api/routes/v2/add.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, BackgroundTasks, HTTPException, Request 2 | from fastapi.responses import JSONResponse 3 | 4 | from pushserver.models.requests import AddRequest, fix_platform_name, AddResponse 5 | from pushserver.resources import settings 6 | from pushserver.resources.storage import TokenStorage 7 | from pushserver.resources.storage.errors import StorageError 8 | from pushserver.resources.utils import (check_host, 9 | log_event, log_add_request) 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.post('/{account}', response_model=AddResponse) 15 | async def add_requests(account: str, 16 | request: Request, 17 | add_request: AddRequest, 18 | background_tasks: BackgroundTasks): 19 | 20 | add_request.platform = fix_platform_name(add_request.platform) 21 | 22 | host, port = request.client.host, request.client.port 23 | 24 | code, description, data = '', '', {} 25 | 26 | if check_host(host, settings.params.allowed_pool): 27 | request_id = f"{account}-{add_request.app_id}-{add_request.device_id}" 28 | if not settings.params.return_async: 29 | background_tasks.add_task(log_add_request, task='log_request', 30 | host=host, loggers=settings.params.loggers, 31 | request_id=request_id, body=add_request.__dict__) 32 | 33 | background_tasks.add_task(log_add_request, task='log_success', 34 | host=host, loggers=settings.params.loggers, 35 | request_id=request_id, body=add_request.__dict__) 36 | 37 | storage = TokenStorage() 38 | background_tasks.add_task(storage.add, account, add_request) 39 | 40 | return add_request 41 | else: 42 | log_add_request(task='log_request', 43 | host=host, loggers=settings.params.loggers, 44 | request_id=request_id, body=add_request.__dict__) 45 | 46 | storage = TokenStorage() 47 | try: 48 | storage.add(account, add_request) 49 | except StorageError: 50 | error = HTTPException(status_code=500, detail="Internal error: storage") 51 | log_add_request(task='log_failure', 52 | host=host, loggers=settings.params.loggers, 53 | request_id=request_id, body=add_request.__dict__, 54 | error_msg=f'500: {{\"detail\": \"{error.detail}\"}}') 55 | raise error 56 | 57 | log_add_request(task='log_success', 58 | host=host, loggers=settings.params.loggers, 59 | request_id=request_id, body=add_request.__dict__) 60 | return add_request 61 | else: 62 | msg = f'incoming request from {host} is denied' 63 | log_event(loggers=settings.params.loggers, 64 | msg=msg, level='deb') 65 | code = 403 66 | description = 'access denied by access list' 67 | data = {} 68 | 69 | log_event(loggers=settings.params.loggers, 70 | msg=msg, level='deb') 71 | 72 | return JSONResponse(status_code=code, content={'code': code, 73 | 'description': description, 74 | 'data': data}) 75 | -------------------------------------------------------------------------------- /pushserver/api/routes/push.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fastapi import APIRouter, BackgroundTasks, Request, status 4 | from fastapi.responses import JSONResponse 5 | 6 | from pushserver.models.requests import WakeUpRequest, fix_platform_name 7 | from pushserver.resources import settings 8 | from pushserver.resources.notification import handle_request 9 | from pushserver.resources.utils import (check_host, 10 | log_event, log_incoming_request) 11 | 12 | router = APIRouter() 13 | 14 | 15 | @router.post('', response_model=WakeUpRequest) 16 | async def push_requests(request: Request, 17 | wp_request: WakeUpRequest, 18 | background_tasks: BackgroundTasks): 19 | 20 | wp_request.platform = fix_platform_name(wp_request.platform) 21 | 22 | host, port = request.client.host, request.client.port 23 | 24 | code, description, data = '', '', {} 25 | 26 | if check_host(host, settings.params.allowed_pool): 27 | request_id = f"{wp_request.event}-{wp_request.app_id}-{wp_request.call_id}" 28 | 29 | if not settings.params.return_async: 30 | background_tasks.add_task(log_incoming_request, task='log_request', 31 | host=host, loggers=settings.params.loggers, 32 | request_id=request_id, body=wp_request.__dict__) 33 | 34 | background_tasks.add_task(log_incoming_request, task='log_success', 35 | host=host, loggers=settings.params.loggers, 36 | request_id=request_id, body=wp_request.__dict__) 37 | background_tasks.add_task(handle_request, 38 | wp_request=wp_request, 39 | request_id=request_id) 40 | status_code, code = status.HTTP_202_ACCEPTED, 202 41 | description, data = 'accepted for delivery', {} 42 | 43 | try: 44 | return JSONResponse(status_code=status_code, 45 | content={'code': code, 46 | 'description': description, 47 | 'data': data}) 48 | except json.decoder.JSONDecodeError: 49 | return JSONResponse(status_code=status_code, 50 | content={'code': code, 51 | 'description': description, 52 | 'data': {}}) 53 | 54 | else: 55 | log_incoming_request(task='log_request', 56 | host=host, loggers=settings.params.loggers, 57 | request_id=request_id, body=wp_request.__dict__) 58 | 59 | log_incoming_request(task='log_success', 60 | host=host, loggers=settings.params.loggers, 61 | request_id=request_id, body=wp_request.__dict__) 62 | results = handle_request(wp_request, request_id=request_id) 63 | code = results.get('code') 64 | description = 'push notification response' 65 | data = results 66 | 67 | else: 68 | msg = f'incoming request from {host} is denied' 69 | log_event(loggers=settings.params.loggers, 70 | msg=msg, level='deb') 71 | code = 403 72 | description = 'access denied by access list' 73 | data = {} 74 | 75 | log_event(loggers=settings.params.loggers, 76 | msg=msg, level='deb') 77 | 78 | return JSONResponse(status_code=code, content={'code': code, 79 | 'description': description, 80 | 'data': data}) 81 | -------------------------------------------------------------------------------- /scripts/sylk-pushclient: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import re 4 | import sys 5 | import requests 6 | 7 | from argparse import ArgumentParser 8 | 9 | if __name__ == '__main__': 10 | parser = ArgumentParser(usage='%(prog)s [options]') 11 | parser.add_argument('--url', dest='url', required=True, help='Push URL') 12 | parser.add_argument('--platform', dest='platform', required=True, help='Platform') 13 | parser.add_argument('--appid', dest='appid', required=True, help='App ID') 14 | parser.add_argument('--token', dest='device_token', required=True, help='Device token') 15 | parser.add_argument('--deviceid', dest='device_id', required=True, help='Device Id') 16 | parser.add_argument('--callid', dest='call_id', required=True, help='Call ID') 17 | parser.add_argument('--event', dest='event', required=False, help='Event', default='incoming_session') 18 | parser.add_argument('--from', dest='from_uri', required=True, help='From') 19 | parser.add_argument('--from_name', dest='from_name', required=False, help='From name') 20 | parser.add_argument('--to', dest='to_uri', required=True, help='To') 21 | parser.add_argument('--silent', dest='silent', default="1", required=False, help='Silent') 22 | parser.add_argument('--mediatype', dest='media_type', default="audio", required=False, help='Audio, Video or Message') 23 | options = parser.parse_args() 24 | 25 | from_uri = re.sub(r'^"|"$', '', options.from_uri) 26 | from_name = options.from_name.strip('\"') if options.from_name else None 27 | 28 | try: 29 | (token1, token2) = options.device_token.split("#") 30 | except ValueError: 31 | token1 = options.device_token 32 | token2 = None 33 | 34 | try: 35 | (token1, token2) = options.device_token.split("-") 36 | except ValueError: 37 | token1 = options.device_token 38 | token2 = None 39 | 40 | media_type = options.media_type 41 | 42 | if ("video" in options.media_type): 43 | media_type = 'video' 44 | elif ("audio" in options.media_type): 45 | media_type = 'audio' 46 | 47 | token = token2 if (token2 and options.event == 'cancel') else token1 48 | 49 | log_params = { 50 | 'platform': options.platform, 51 | 'app-id': options.appid, 52 | 'token': token, 53 | 'media-type': media_type, 54 | 'event': options.event, 55 | 'from': from_uri, 56 | 'from-display-name': from_name or from_uri, 57 | 'to': options.to_uri, 58 | 'device-id': options.device_id, 59 | 'call-id': options.call_id, 60 | 'silent': options.silent 61 | } 62 | 63 | try: 64 | r = requests.post(options.url, timeout=5, json=log_params) 65 | if r.status_code == 200: 66 | print("%s push for %s to %s response 200 OK: %s" % (options.event, options.call_id, options.url, r.text)) 67 | body = r.json() 68 | try: 69 | failure = body['data']['body']['_content']['failure'] 70 | if failure == 1: 71 | # A push client may want to act based on various response codes 72 | # https://firebase.google.com/docs/cloud-messaging/http-server-ref#error-codes 73 | reason = body['data']['body']['_content']['results'][0]['error'] 74 | if reason == 'NotRegistered': 75 | print("Token %s must be purged" % token) 76 | #q = "delete from push_tokens where token = '%s'" % token 77 | #con = pymysql.connect('localhost', 'opensips', 'XYZ', 'opensips') 78 | #with con: 79 | # cur = con.cursor() 80 | # cur.execute(q) 81 | except KeyError: 82 | pass 83 | 84 | sys.exit(0) 85 | else: 86 | print("%s push for %s to %s failed: %d: %s" % (options.event, options.call_id, options.url, r.status_code, r.text)) 87 | sys.exit(1) 88 | except Exception as e: 89 | print("%s push for %s to %s failed: connection error" % (options.event, options.call_id, options.url)) 90 | sys.exit(1) 91 | -------------------------------------------------------------------------------- /pushserver/resources/notification.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import json 3 | 4 | from pushserver.models.requests import WakeUpRequest 5 | from pushserver.resources import settings 6 | 7 | 8 | def handle_request(wp_request, request_id: str) -> dict: 9 | """ 10 | Create a PushNotification object, 11 | and call methods to send the notification. 12 | 13 | :param wp_request: `WakeUpRequest', received from /push route. 14 | :param loggers: `dict` global logging instances to write messages (params.loggers) 15 | :param request_id: `str`, request ID generated on request event. 16 | :return: a `dict` with push notification results 17 | """ 18 | push_notification = PushNotification(wp_request=wp_request, request_id=request_id) 19 | results = push_notification.send_notification() 20 | return results 21 | 22 | 23 | class PushNotification(object): 24 | """ 25 | Push Notification actions from wake up request 26 | """ 27 | 28 | def __init__(self, wp_request: WakeUpRequest, request_id: str): 29 | """ 30 | :param wp_request: `WakeUpRequest`, from http request 31 | :param request_id: `str`, request ID generated on request event. 32 | """ 33 | self.wp_request = wp_request 34 | self.app_id = self.wp_request.app_id 35 | self.platform = self.wp_request.platform 36 | self.pns_register = settings.params.pns_register 37 | self.request_id = request_id 38 | self.loggers = settings.params.loggers 39 | self.log_remote = self.pns_register[(self.app_id, self.platform)].get('log_remote') 40 | self.config_dict = self.pns_register[(self.app_id, self.platform)] 41 | 42 | self.app_name = self.pns_register[(self.app_id, self.platform)]['name'] 43 | 44 | self.args = [self.app_id, self.wp_request.event, self.wp_request.token, 45 | self.wp_request.call_id, self.wp_request.sip_from, 46 | self.wp_request.from_display_name, self.wp_request.sip_to, 47 | self.wp_request.media_type, self.wp_request.silent, 48 | self.wp_request.reason, self.wp_request.badge, 49 | self.wp_request.filename, self.wp_request.filetype, 50 | self.wp_request.account, self.wp_request.content, 51 | self.wp_request.content_type] 52 | 53 | @property 54 | def custom_apps(self): 55 | apps = [self.pns_register[key]['name'] for key in self.pns_register.keys()] 56 | custom_apps = set(app for app in apps if app not in ('sylk', 'linphone')) 57 | return custom_apps 58 | 59 | def send_notification(self) -> dict: 60 | """ 61 | Send a push notification according to wakeup request params. 62 | """ 63 | error = '' 64 | headers_class = self.pns_register[(self.app_id, self.platform)]['headers_class'] 65 | headers = headers_class(*self.args).headers 66 | 67 | payload_class = self.pns_register[(self.app_id, self.platform)]['payload_class'] 68 | payload_dict = payload_class(*self.args).payload 69 | try: 70 | payload = json.dumps(payload_dict) 71 | except Exception: 72 | payload = None 73 | 74 | if not (headers and payload): 75 | error = f'{headers_class.__name__} and {payload_class.__name__} ' \ 76 | f'returned bad objects:' \ 77 | f'{headers}, {payload}' 78 | 79 | if not isinstance(headers, dict) or not isinstance(payload, str): 80 | error = f'{headers_class.__name__} and {payload_class.__name__} ' \ 81 | f'returned bad objects:' \ 82 | f'{headers}, {payload}' 83 | 84 | register = self.pns_register[(self.app_id, self.platform)] 85 | platform_module = importlib.import_module(f'pushserver.pns.{self.platform}') 86 | 87 | push_request_class = getattr(platform_module, 88 | f'{self.platform.capitalize()}PushRequest') 89 | 90 | push_request = push_request_class(error=error, 91 | app_name=self.app_name, 92 | app_id=self.app_id, 93 | request_id=self.request_id, 94 | headers=headers, 95 | payload=payload, 96 | loggers=self.loggers, 97 | log_remote=self.log_remote, 98 | wp_request=self.wp_request, 99 | register=register) 100 | return push_request.results 101 | -------------------------------------------------------------------------------- /config/applications.ini.sample: -------------------------------------------------------------------------------- 1 | ; this file contains mobile applications 2 | ; the unique key is (app_id, platform, app_type) 3 | 4 | [myapp-apple] 5 | ; app_id = com.agprojects.sylk-ios 6 | 7 | ; application type must be defince in the code, curently linphone and sylk 8 | ; are supported 9 | ; app_type = sylk 10 | 11 | ; platform can be apple or firebase 12 | ; app_platform = apple 13 | 14 | ; these are apple platform related settings 15 | ; apple_certificate = com.myapp-ios.pem 16 | ; apple_key = com.myapp-ios.key.pem 17 | ; apple_push_url = api.sandbox.push.apple.com 18 | 19 | ; if voip is True, different headers will be generated for the push request, 20 | ; consult Apple documentation for more details. 21 | ; voip = True 22 | 23 | ; log the requests for remote logging 24 | ; log_remote_urls = https://myapp.net, https://example.com 25 | 26 | ; if log_remote_urls is set, a POST will be executed to the urls containing 27 | ; the original request and the final response,: 28 | ; payload = {'request': push_request, 'response': push_response} 29 | ; see readme.md for a detail description 30 | 31 | 32 | ; log_remote_timeout = 5 33 | 34 | ; if the key below is defined, the logline will contain the value of the 35 | ; key present in the json returned by the remote log server, if not key is 36 | ; defined the entire body will be logged if debub is True, otherwise only 37 | ; the response code will be logged 38 | ; log_remote_key = message 39 | 40 | 41 | [myapp-apple2] 42 | ; app_id = com.agprojects.sylk-ios 43 | 44 | ; application type must be defince in the code, curently linphone and sylk 45 | ; are supported 46 | ; app_type = sylk 47 | 48 | ; platform can be apple or firebase 49 | ; app_platform = apple 50 | 51 | ; these are apple platform related settings 52 | ; apple_key = AuthKey_KEYID.p8 53 | ; key_id = ***Key ID for key in p8 file*** 54 | ; team_id = ***The Apple Team ID for your developer team*** 55 | ; apple_push_url = api.sandbox.push.apple.com 56 | 57 | ; if voip is True, different headers will be generated for the push request, 58 | ; consult Apple documentation for more details. 59 | ; voip = True 60 | 61 | ; log the requests for remote logging 62 | ; log_remote_urls = https://myapp.net, https://example.com 63 | 64 | ; if log_remote_urls is set, a POST will be executed to the urls containing 65 | ; the original request and the final response,: 66 | ; payload = {'request': push_request, 'response': push_response} 67 | ; see readme.md for a detail description 68 | 69 | 70 | ; log_remote_timeout = 5 71 | 72 | ; if the key below is defined, the logline will contain the value of the 73 | ; key present in the json returned by the remote log server, if not key is 74 | ; defined the entire body will be logged if debub is True, otherwise only 75 | ; the response code will be logged 76 | ; log_remote_key = message 77 | 78 | 79 | [myapp-firebase] 80 | ; app_id = com.agprojects.sylk 81 | ; app_type = sylk 82 | ; app_platform = firebase 83 | ; firebase_authorization_file = credentials/myapp-xxxxx-firebase-adminsdk-xxxxx-xxxxxxxx.json 84 | ; firebase_push_url = https://fcm.googleapis.com/v1/projects/myapp-xxxxx/messages:send 85 | 86 | ; log the requests for remote logging 87 | ; log_remote_urls = https://myapp.net, https://example.com 88 | 89 | ; if log_remote_urls is set, a POST will be executed to the urls containing 90 | ; the original request and the final response,: 91 | ; payload = {'request': push_request, 'response': push_response} 92 | ; see readme.md for a detail description 93 | 94 | 95 | ; log_remote_timeout = 5 96 | 97 | ; if the key below is defined, the logline will contain the value of the 98 | ; key present in the json returned by the remote log server, if not key is 99 | ; defined the entire body will be logged if debub is True, otherwise only 100 | ; the response code will be logged 101 | ; log_remote_key = message 102 | 103 | 104 | [myapp-firebase2] 105 | ; app_id = myapp 106 | ; app_type = linphone 107 | ; app_platform = firebase 108 | ; firebase_authorization_key = ****** 109 | ; firebase_push_url = https://fcm.googleapis.com/fcm/send 110 | 111 | ; log the requests for remote logging 112 | ; log_remote_urls = https://myapp.net, https://example.com 113 | 114 | ; if log_remote_urls is set, a POST will be executed to the urls containing 115 | ; the original request and the final response,: 116 | ; payload = {'request': push_request, 'response': push_response} 117 | ; see readme.md for a detail description 118 | 119 | 120 | ; log_remote_timeout = 5 121 | 122 | ; if the key below is defined, the logline will contain the value of the 123 | ; key present in the json returned by the remote log server, if not key is 124 | ; defined the entire body will be logged if debub is True, otherwise only 125 | ; the response code will be logged 126 | ; log_remote_key = message 127 | -------------------------------------------------------------------------------- /pushserver/applications/linphone.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pushserver.applications.apple import * 4 | from pushserver.applications.firebase import * 5 | 6 | __all__ = ['AppleLinphoneHeaders', 'AppleLinphonePayload', 7 | 'FirebaseLinphoneHeaders', 'FirebaseLinphonePayload'] 8 | 9 | 10 | class AppleLinphoneHeaders(AppleHeaders): 11 | """ 12 | An Apple headers structure for a push notification 13 | """ 14 | 15 | def create_push_type(self) -> str: 16 | """ 17 | logic to define apns_push_type value using request parameters 18 | apns_push_type reflect the contents of the notification’s payload, 19 | it can be: 20 | 'alert', 'background', 'voip', 21 | 'complication', 'fileprovider' or 'mdm'. 22 | """ 23 | return 'voip' 24 | 25 | def create_expiration(self) -> int: 26 | """ 27 | logic to define apns_expiration value using request parameters 28 | apns_expiration is the date at which the notification expires, 29 | (UNIX epoch expressed in seconds UTC). 30 | """ 31 | return '10' 32 | 33 | def create_topic(self) -> str: 34 | """ 35 | Define a valid apns topic 36 | based on app_id value, without 'prom' or 'dev' 37 | 38 | :return: a `str` with a valid apns topic 39 | """ 40 | 41 | if self.app_id.endswith('.dev') or self.app_id.endswith('.prod'): 42 | apns_topic = '.'.join(self.app_id.split('.')[:-1]) 43 | else: 44 | apns_topic = self.app_id 45 | if not '.voip' in apns_topic: 46 | apns_topic = f"{apns_topic}.voip" 47 | 48 | return apns_topic 49 | 50 | def create_priority(self) -> int: 51 | """ 52 | logic to define apns_priority value using request parameters 53 | Notification priority, 54 | apns_prioriy 10 o send the notification immediately, 55 | 5 to send the notification based on power considerations 56 | on the user’s device. 57 | """ 58 | return '10' 59 | 60 | 61 | class FirebaseLinphoneHeaders(FirebaseHeaders): 62 | """ 63 | Firebase headers for a push notification 64 | """ 65 | 66 | 67 | class AppleLinphonePayload(ApplePayload): 68 | """ 69 | An Apple payload for a Linphone push notification 70 | """ 71 | 72 | @property 73 | def payload(self) -> dict: 74 | """ 75 | Generate apple notification payload 76 | 77 | :param silent: `bool` True for silent notification. 78 | :return: A `json` with a push notification payload. 79 | 80 | """ 81 | 82 | now = datetime.now() 83 | send_time = now.strftime('%Y-%m-%d %H:%M:%S') 84 | 85 | if self.silent: 86 | payload = {'aps': {'sound': '', 87 | 'loc-key': 'IC_SIL', 88 | 'call-id': self.call_id, 89 | 'send-time': send_time}, 90 | 'from-uri': self.sip_from, 91 | 'pn_ttl': 2592000} 92 | else: 93 | payload = {'aps': {'alert': {'loc-key': 'IC_MSG', 94 | 'loc-args': self.sip_from}, 95 | 'sound': 'msg.caf', 'badge': 1}, 96 | 'pn_ttl': 2592000, 97 | 'call-id': self.call_id, 98 | 'send-time': send_time} 99 | 100 | return payload 101 | 102 | 103 | class FirebaseLinphonePayload(FirebasePayload): 104 | """ 105 | A Firebase payload for a Linphone push notification 106 | """ 107 | 108 | @property 109 | def payload(self) -> dict: 110 | """ 111 | Generate a Firebase payload for a push notification 112 | 113 | :return a Firebase payload: 114 | """ 115 | 116 | now = datetime.now() 117 | send_time = now.strftime('%Y-%m-%d %H:%M:%S') 118 | 119 | data = {'call-id': self.call_id, 120 | 'sip-from': self.sip_from, 121 | 'loc-key': '', 122 | 'loc-args': self.sip_from, 123 | 'send-time': send_time} 124 | 125 | payload = {'to': self.token, 126 | 'time_to_live': 2419199, 127 | 'priority': 'high', 128 | 'data': data} 129 | 130 | if self.auth_file: 131 | payload = { 132 | 'message': { 133 | 'token': self.token, 134 | 'data': data, 135 | 'android': { 136 | 'priority': 'high', 137 | 'ttl': '60s' 138 | } 139 | } 140 | } 141 | 142 | return payload 143 | 144 | -------------------------------------------------------------------------------- /pushserver/api/routes/v2/remove.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status 2 | from fastapi.responses import JSONResponse 3 | 4 | from pushserver.models.requests import RemoveRequest, RemoveResponse 5 | from pushserver.resources import settings 6 | from pushserver.resources.storage import TokenStorage 7 | from pushserver.resources.storage.errors import StorageError 8 | from pushserver.resources.utils import (check_host, 9 | log_event, log_remove_request) 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.delete('/{account}', response_model=RemoveResponse) 15 | async def remove_requests(account: str, 16 | request: Request, 17 | rm_request: RemoveRequest, 18 | background_tasks: BackgroundTasks): 19 | 20 | host, port = request.client.host, request.client.port 21 | 22 | code, description, data = '', '', {} 23 | 24 | if check_host(host, settings.params.allowed_pool): 25 | request_id = f"{account}-{rm_request.app_id}-{rm_request.device_id}" 26 | 27 | if not settings.params.return_async: 28 | background_tasks.add_task(log_remove_request, task='log_request', 29 | host=host, loggers=settings.params.loggers, 30 | request_id=request_id, body=rm_request.__dict__) 31 | background_tasks.add_task(log_remove_request, task='log_success', 32 | host=host, loggers=settings.params.loggers, 33 | request_id=request_id, body=rm_request.__dict__) 34 | storage = TokenStorage() 35 | background_tasks.add_task(storage.remove, account, rm_request.app_id, rm_request.device_id) 36 | return rm_request 37 | else: 38 | log_remove_request(task='log_request', 39 | host=host, loggers=settings.params.loggers, 40 | request_id=request_id, body=rm_request.__dict__) 41 | 42 | storage = TokenStorage() 43 | try: 44 | storage_data = storage[account] 45 | except StorageError: 46 | error = HTTPException(status_code=500, detail="Internal error: storage") 47 | log_remove_request(task='log_failure', 48 | host=host, loggers=settings.params.loggers, 49 | request_id=request_id, body=rm_request.__dict__, 50 | error_msg=f'500: {{\"detail\": \"{error.detail}\"}}') 51 | raise error 52 | if not storage_data: 53 | log_remove_request(task='log_failure', 54 | host=host, loggers=settings.params.loggers, 55 | request_id=request_id, body=rm_request.__dict__, 56 | error_msg="User not found in token storage") 57 | return JSONResponse( 58 | status_code=status.HTTP_404_NOT_FOUND, 59 | content={'result': 'User not found'} 60 | ) 61 | 62 | device_id = f"{rm_request.app_id}-{rm_request.device_id}" 63 | try: 64 | device = storage_data[device_id] 65 | except KeyError: 66 | log_remove_request(task='log_failure', 67 | host=host, loggers=settings.params.loggers, 68 | request_id=request_id, body=rm_request.__dict__, 69 | error_msg="Device or app_id not found in token storage") 70 | return JSONResponse( 71 | status_code=status.HTTP_404_NOT_FOUND, 72 | content={'result': 'Not found'} 73 | ) 74 | else: 75 | storage.remove(account, rm_request.app_id, rm_request.device_id) 76 | log_remove_request(task='log_success', 77 | host=host, loggers=settings.params.loggers, 78 | request_id=request_id, body=rm_request.__dict__) 79 | msg = f'Removing {device}' 80 | log_event(loggers=settings.params.loggers, 81 | msg=msg, level='deb') 82 | return rm_request 83 | 84 | else: 85 | msg = f'incoming request from {host} is denied' 86 | log_event(loggers=settings.params.loggers, 87 | msg=msg, level='deb') 88 | code = 403 89 | description = 'access denied by access list' 90 | data = {} 91 | 92 | log_event(loggers=settings.params.loggers, 93 | msg=msg, level='deb') 94 | 95 | return JSONResponse(status_code=code, content={'code': code, 96 | 'description': description, 97 | 'data': data}) 98 | -------------------------------------------------------------------------------- /sylk-pushserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import logging 4 | import os 5 | import sys 6 | import uvicorn 7 | from pushserver import __info__ as package_info 8 | 9 | from application.process import process 10 | from application import log 11 | from pushserver.resources import settings 12 | from pushserver.resources.utils import (log_event, resources_available, 13 | ssl_cert, try_again) 14 | 15 | name = 'sylk-pushserver' 16 | fullname = "Sylk Pushserver" 17 | 18 | logging.getLogger("uvicorn").setLevel(logging.WARN) 19 | 20 | default_dir = '/etc/sylk-pushserver' 21 | 22 | parser = argparse.ArgumentParser(add_help=False) 23 | parser.add_argument('-h', '--help', 24 | action='help', 25 | default=argparse.SUPPRESS, 26 | help='Show this help message and exit.') 27 | 28 | parser.add_argument("--ip", 29 | default='', 30 | help="If set, server will run in its address") 31 | 32 | parser.add_argument("--port", 33 | default='', 34 | help="If set, server will run in its address") 35 | 36 | parser.add_argument("--config_dir", 37 | default=None, 38 | metavar='PATH', 39 | help="Specify a config directory that contains " 40 | "general.ini, applications.ini and " 41 | "the credentials directory, " 42 | "Default it uses '/etc/sylk-pushserver'") 43 | 44 | parser.add_argument('--no-fork', 45 | action='store_false', 46 | dest='fork', 47 | help='log and run in the foreground') 48 | 49 | parser.add_argument("--debug", 50 | action="store_true", 51 | default=False, 52 | help="If set, log headers and body requests to log file.") 53 | 54 | args = parser.parse_args() 55 | 56 | if args.fork: 57 | try: 58 | from cysystemd.journal import JournaldLogHandler 59 | except ImportError: 60 | from systemd.journal import JournalHandler 61 | 62 | try: 63 | journal_handler = JournaldLogHandler() 64 | except NameError: 65 | journal_handler = JournalHandler(SYSLOG_IDENTIFIER='sylk-pushserver') 66 | 67 | log.set_handler(journal_handler) 68 | log.capture_output() 69 | 70 | root_logger = log.get_logger() 71 | root_logger.name = name 72 | 73 | if not args.fork: 74 | console_formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d %H:%M:%S') 75 | log.set_default_formatter(console_formatter) 76 | 77 | log.info('Starting %s %s' % (fullname, package_info.__version__)) 78 | config_dir = default_dir 79 | 80 | if args.config_dir is not None: 81 | if not os.path.exists(f'{args.config_dir}'): 82 | log.info('Specified config directory does not exist') 83 | sys.exit(1) 84 | config_dir = args.config_dir 85 | 86 | settings.init(config_dir, args.debug, args.ip, args.port) 87 | 88 | # Since TokenStorage config relies on the config_dir it has to be imported here 89 | process.configuration.local_directory = config_dir 90 | from pushserver.resources.storage import TokenStorage 91 | 92 | if __name__ == '__main__': 93 | 94 | if not settings.params.dir['error'] or 'default' in settings.params.dir['error']: 95 | storage = TokenStorage() 96 | storage.load() 97 | sock_available = False 98 | while not sock_available: 99 | host = settings.params.server['host'] 100 | port = int(settings.params.server['port']) 101 | tls_cert = settings.params.server['tls_cert'] 102 | sock_available = resources_available(host, port) 103 | if sock_available: 104 | if tls_cert: 105 | if ssl_cert(tls_cert): 106 | msg = 'Starting app over SSL...' 107 | print(msg) 108 | log_event(loggers=settings.params.loggers, 109 | msg=msg, level='info') 110 | uvicorn.run('pushserver.resources.app:app', host=host, 111 | port=port, ssl_certfile=tls_cert, 112 | acces_log=False, log_level='error') 113 | break 114 | else: 115 | msg = f'{tls_cert} is not a valid ssl cert, app will be run without it' 116 | print(msg) 117 | log_event(loggers=settings.params.loggers, 118 | msg=msg, level='deb') 119 | uvicorn.run('pushserver.resources.server:server', 120 | host=host, port=port, 121 | access_log=False, log_level='error') 122 | break 123 | else: 124 | uvicorn.run('pushserver.resources.server:server', 125 | host=host, port=port, 126 | access_log=False, log_level='error') 127 | break 128 | else: 129 | try_again(timer=30, 130 | host=host, port=port, 131 | start_error=settings.params.dir['error'], 132 | loggers=settings.params.loggers) 133 | 134 | else: 135 | log_event(loggers=settings.params.loggers, 136 | msg=settings.params.dir['error'], 137 | level='error') 138 | print(settings.params.dir['error']) 139 | -------------------------------------------------------------------------------- /pushserver/applications/firebase.py: -------------------------------------------------------------------------------- 1 | from oauth2client.service_account import ServiceAccountCredentials 2 | 3 | from pushserver.resources import settings 4 | 5 | __all__ = ['FirebaseHeaders', 'FirebasePayload'] 6 | 7 | 8 | class FirebaseHeaders(object): 9 | def __init__(self, app_id: str, event: str, token: str, 10 | call_id: str, sip_from: str, from_display_name: str, 11 | sip_to: str, media_type: str, silent: bool, reason: str, 12 | badge: int, filename: str, filetype: str, account: str, 13 | content: str, content_type: str): 14 | """ 15 | :param app_id: `str` id provided by the mobile application (bundle id) 16 | :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' 17 | :param token: `str` destination device token. 18 | :param call_id: `str` unique sip parameter. 19 | :param sip_from: `str` SIP URI for who is calling 20 | :param from_display_name: `str` display name of the caller 21 | :param sip_to: `str` SIP URI for who is called 22 | :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' 23 | :param silent: `bool` True for silent notification 24 | :param reason: `str` Cancel reason 25 | :param badge: `int` Number to display as badge 26 | """ 27 | 28 | self.app_id = app_id 29 | self.token = token 30 | self.call_id = call_id 31 | self.sip_from = sip_from 32 | self.sip_to = sip_to 33 | self.from_display_name = from_display_name 34 | self.media_type = media_type 35 | self.silent = silent 36 | self.event = event 37 | self.reason = reason 38 | self.badge = badge 39 | self.filename = filename 40 | self.filetype = filetype 41 | self.account = account 42 | self.content = content 43 | self.content_type = content_type 44 | 45 | try: 46 | self.auth_key = settings.params.pns_register[(self.app_id, 'firebase')]['auth_key'] 47 | except KeyError: 48 | self.auth_key = None 49 | 50 | try: 51 | self.auth_file = settings.params.pns_register[(self.app_id, 'firebase')]['auth_file'] 52 | except KeyError: 53 | self.auth_file = None 54 | 55 | @property 56 | def access_token(self) -> str: 57 | # https://github.com/firebase/quickstart-python/blob/909f39e77395cb0682108184ba565150caa68a31/messaging/messaging.py#L25-L33 58 | 59 | """ 60 | Retrieve a valid access token that can be used to authorize requests. 61 | :return: `str` Access token. 62 | """ 63 | scopes = ['https://www.googleapis.com/auth/firebase.messaging'] 64 | try: 65 | credentials = ServiceAccountCredentials.from_json_keyfile_name(self.auth_file, scopes) 66 | access_token_info = credentials.get_access_token() 67 | return access_token_info.access_token 68 | except Exception as e: 69 | self.error = f"Error: cannot generated Firebase access token: {e}" 70 | return '' 71 | 72 | @property 73 | def headers(self): 74 | """ 75 | Generate Firebase headers structure for a push notification 76 | 77 | :return: a firebase push notification header. 78 | """ 79 | if self.auth_key: 80 | headers = {'Content-Type': 'application/json', 81 | 'Authorization': f"key={self.auth_key}"} 82 | else: 83 | headers = { 84 | 'Authorization': f"Bearer {self.access_token}", 85 | 'Content-Type': 'application/json; UTF-8', 86 | } 87 | 88 | return headers 89 | 90 | 91 | class FirebasePayload(object): 92 | def __init__(self, app_id: str, event: str, token: str, 93 | call_id: str, sip_from: str, from_display_name: str, 94 | sip_to: str, media_type: str, silent: bool, reason: str, 95 | badge: int, filename: str, filetype: str, account: str, 96 | content: str, content_type: str): 97 | """ 98 | :param app_id: `str` id provided by the mobile application (bundle id) 99 | :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' 100 | :param token: `str` destination device token. 101 | :param call_id: `str` unique sip parameter. 102 | :param sip_from: `str` SIP URI for who is calling 103 | :param from_display_name: `str` display name of the caller 104 | :param sip_to: `str` SIP URI for who is called 105 | :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' 106 | :param silent: `bool` True for silent notification 107 | :param reason: `str` Cancel reason 108 | :param badge: `int` Number to display as badge 109 | """ 110 | self.app_id = app_id 111 | self.token = token 112 | self.call_id = call_id # corresponds to session_id in the output 113 | self.event = event 114 | self.media_type = media_type 115 | self.sip_from = sip_from 116 | self.from_display_name = from_display_name 117 | self.sip_to = sip_to 118 | self.silent = silent 119 | self.reason = reason 120 | self.badge = badge 121 | self.filename = filename 122 | self.filetype = filetype 123 | self.account = account 124 | self.content = content 125 | self.content_type = content_type 126 | 127 | try: 128 | self.auth_file = settings.params.pns_register[(self.app_id, 'firebase')]['auth_file'] 129 | except KeyError: 130 | self.auth_file = None 131 | 132 | @property 133 | def payload(self) -> dict: 134 | """ 135 | Generate a Firebase payload for a push notification 136 | 137 | :return a Firebase payload: 138 | """ 139 | payload = {} 140 | return payload 141 | -------------------------------------------------------------------------------- /pushserver/applications/apple.py: -------------------------------------------------------------------------------- 1 | from pushserver.resources import settings 2 | 3 | __all__ = ['AppleHeaders', 'ApplePayload'] 4 | 5 | 6 | class AppleHeaders(object): 7 | """ 8 | Apple headers structure for a push notification 9 | """ 10 | 11 | def __init__(self, app_id: str, event: str, token: str, 12 | call_id: str, sip_from: str, from_display_name: str, 13 | sip_to: str, media_type: str, silent: bool, reason: str, 14 | badge: int, filename: str, filetype: str, account: str, 15 | content: str, content_type: str): 16 | """ 17 | :param app_id: `str` id provided by the mobile application (bundle id) 18 | :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' 19 | :param token: `str` destination device token. 20 | :param call_id: `str` unique sip parameter. 21 | :param sip_from: `str` SIP URI for who is calling 22 | :param from_display_name: `str` display name of the caller 23 | :param sip_to: `str` SIP URI for who is called 24 | :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' 25 | :param silent: `bool` True for silent notification 26 | :param reason: `str` Cancel reason 27 | :param badge: `int` Number to display as badge 28 | """ 29 | 30 | self.app_id = app_id 31 | self.token = token 32 | self.call_id = call_id 33 | self.sip_from = sip_from 34 | self.sip_to = sip_to 35 | self.from_display_name = from_display_name 36 | self.media_type = media_type 37 | self.silent = silent 38 | self.event = event 39 | self.reason = reason 40 | self.badge = badge 41 | self.filename = filename 42 | self.filetype = filetype 43 | self.account = account 44 | self.content = content 45 | self.content_type = content_type 46 | 47 | self.apns_push_type = self.create_push_type() 48 | self.apns_expiration = self.create_expiration() 49 | self.apns_topic = self.create_topic() 50 | self.apns_priority = self.create_priority() 51 | 52 | self.auth_token = settings.params.pns_register[(self.app_id, 'apple')]['pns'].auth_token 53 | 54 | def create_push_type(self) -> str: 55 | """ 56 | logic to define apns_push_type value using request parameters 57 | apns_push_type reflect the contents of the notification’s payload, 58 | it can be: 59 | 'alert', 'background', 'voip', 60 | 'complication', 'fileprovider' or 'mdm'. 61 | """ 62 | return 63 | 64 | def create_expiration(self) -> int: 65 | """ 66 | logic to define apns_expiration value using request parameters 67 | apns_expiration is the date at which the notification expires, 68 | (UNIX epoch expressed in seconds UTC). 69 | """ 70 | return 71 | 72 | def create_topic(self) -> str: 73 | """ 74 | logic to define apns_topic value using request parameters 75 | apns_topic is in general is the app’s bundle ID and may have 76 | a suffix based on the notification’s type. 77 | """ 78 | return 79 | 80 | def create_priority(self) -> int: 81 | """ 82 | logic to define apns_priority value using request parameters 83 | Notification priority, 84 | apns_prioriy 10 o send the notification immediately, 85 | 5 to send the notification based on power considerations 86 | on the user’s device. 87 | """ 88 | return 89 | 90 | @property 91 | def headers(self) -> dict: 92 | """ 93 | Generate apple notification headers 94 | 95 | :return: a `dict` object with headers. 96 | """ 97 | 98 | headers = { 99 | 'apns-push-type': self.apns_push_type, 100 | 'apns-expiration': self.apns_expiration, 101 | 'apns-priority': self.apns_priority, 102 | 'apns-topic': self.apns_topic, 103 | } 104 | 105 | if self.auth_token: 106 | headers['authorization'] = f"bearer {self.auth_token}" 107 | 108 | if self.apns_push_type == 'background': 109 | headers['content-available'] = '1' 110 | 111 | return headers 112 | 113 | 114 | class ApplePayload(object): 115 | """ 116 | Apple payload structure for a push notification 117 | """ 118 | 119 | def __init__(self, app_id: str, event: str, token: str, 120 | call_id: str, sip_from: str, from_display_name: str, 121 | sip_to: str, media_type, silent: bool, reason: str, 122 | badge: int, filename: str, filetype: str, account: str, 123 | content: str, content_type: str): 124 | """ 125 | :param app_id: `str` id provided by the mobile application (bundle id) 126 | :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' 127 | :param token: `str` destination device token. 128 | :param call_id: `str` unique sip parameter. 129 | :param sip_from: `str` SIP URI for who is calling 130 | :param from_display_name: `str` display name of the caller 131 | :param sip_to: `str` SIP URI for who is called 132 | :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' 133 | :param silent: `bool` True for silent notification 134 | :param reason: `str` Cancel reason 135 | :param badge: `int` Number to display as badge 136 | """ 137 | self.app_id = app_id 138 | self.token = token 139 | self.call_id = call_id 140 | self.sip_from = sip_from 141 | self.sip_to = sip_to 142 | self.from_display_name = from_display_name 143 | self.media_type = media_type 144 | self.silent = silent 145 | self.event = event 146 | self.reason = reason 147 | self.badge = badge 148 | self.filename = filename 149 | self.filetype = filetype 150 | self.account = account 151 | self.content = content 152 | self.content_type = content_type 153 | 154 | @property 155 | def payload(self) -> dict: 156 | """ 157 | logic to define apple payload using request parameters 158 | """ 159 | 160 | payload = {} 161 | return payload 162 | 163 | -------------------------------------------------------------------------------- /pushserver/resources/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from typing import Callable 5 | 6 | from fastapi import FastAPI 7 | from fastapi.exceptions import RequestValidationError 8 | from pushserver import __info__ as package_info 9 | from pushserver.api.errors.validation_error import validation_exception_handler 10 | from pushserver.api.routes.api import router 11 | from pushserver.resources import settings 12 | from pushserver.resources.utils import log_event 13 | 14 | 15 | def get_server() -> FastAPI: 16 | server = FastAPI(title='sylk-pushserver', version=package_info.__version__, debug=True) 17 | server.add_event_handler("startup", create_start_server_handler()) 18 | server.add_exception_handler(RequestValidationError, validation_exception_handler) 19 | server.include_router(router) 20 | return server 21 | 22 | 23 | async def autoreload_read_config(wait_for: float = 0.1) -> None: 24 | """ 25 | Set global parameters when config folder changes. 26 | :param wait_for: `float` time to sleep between looks for changes. 27 | """ 28 | # thanks to lbellomo for the concept of this function 29 | 30 | to_watch = {} 31 | paths_list = [settings.params.file['path'], 32 | settings.params.apps['path'], 33 | settings.params.apps['credentials']] 34 | for path in paths_list: 35 | try: 36 | to_watch[path] = os.stat(path).st_mtime 37 | except FileNotFoundError: 38 | pass 39 | 40 | while True: 41 | for path in to_watch.keys(): 42 | last_st_mtime = to_watch[path] 43 | path_modified = last_st_mtime != os.stat(path).st_mtime 44 | if path_modified: 45 | to_watch[path] = os.stat(path).st_mtime 46 | settings.params = settings.update_params(settings.params.config_dir, 47 | settings.params.debug, 48 | settings.params.ip, 49 | settings.params.port) 50 | await asyncio.sleep(wait_for) 51 | break 52 | await asyncio.sleep(wait_for) 53 | 54 | 55 | def create_start_server_handler() -> Callable: # type: ignore 56 | wait_for = 0.1 57 | 58 | async def start_server() -> None: 59 | 60 | asyncio.create_task(autoreload_read_config(wait_for=wait_for)) 61 | 62 | level = 'info' 63 | loggers = settings.params.loggers 64 | register = settings.params.register 65 | 66 | pns_register = register['pns_register'] 67 | if settings.params.apps['path']: 68 | msg = f"Loaded {len(pns_register)} applications from " \ 69 | f"{settings.params.apps['path']}:" 70 | else: 71 | msg = f"Loaded {len(pns_register)} applications" 72 | log_event(loggers=loggers, msg=msg, level=level) 73 | 74 | for app in pns_register.keys(): 75 | app_id, platform = app 76 | name = pns_register[app]['name'] 77 | msg = f"Loaded {platform.capitalize()} "\ 78 | f"{name.capitalize()} app {app_id}" \ 79 | 80 | log_event(loggers=loggers, msg=msg, level=level) 81 | 82 | headers_class = pns_register[app]['headers_class'] 83 | payload_class = pns_register[app]['payload_class'] 84 | 85 | msg = f"{name.capitalize()} app {app_id} classes: " \ 86 | f"{headers_class.__name__}, {payload_class.__name__}" 87 | log_event(loggers=loggers, msg=msg, level='deb') 88 | 89 | log_remote = pns_register[app]['log_remote'] 90 | if log_remote['error']: 91 | msg = f"{name.capitalize()} loading of log remote settings failed: " \ 92 | f"{log_remote['error']}" 93 | log_event(loggers=loggers, msg=msg, level='warn') 94 | elif log_remote.get('log_remote_urls'): 95 | log_settings = '' 96 | for k, v in log_remote.items(): 97 | if k == 'error': 98 | continue 99 | if k == 'log_urls': 100 | v = ', '.join(v) 101 | if k == 'log_remote_key' and not v: 102 | continue 103 | if k == 'log_remote_timeout' and not v: 104 | continue 105 | log_settings += f'{k}: {v} ' 106 | msg = f'{name.capitalize()} log remote settings: {log_settings}' 107 | log_event(loggers=loggers, msg=msg, level='deb') 108 | 109 | invalid_apps = register['invalid_apps'] 110 | for app in invalid_apps.keys(): 111 | app_id, platform = app[0], app[1] 112 | name = invalid_apps[app]['name'] 113 | reason = invalid_apps[app]['reason'] 114 | msg = f"{name.capitalize()} app with {app_id} id for {platform} platform " \ 115 | f"will not be available, reason: {reason}" 116 | log_event(loggers=loggers, msg=msg, level='warn') 117 | 118 | pnses = register['pnses'] 119 | 120 | if len(pnses) == 0: 121 | msg = f'Loaded {len(pnses)} Push notification services' 122 | else: 123 | msg = f'Loaded {len(pnses)} Push notification services: ' \ 124 | f'{", ".join(pnses)}' 125 | log_event(loggers=loggers, msg=msg, level='deb') 126 | 127 | for pns in pnses: 128 | msg = f"{pns.split('PNS')[0]} Push Notification Service - " \ 129 | f"{pns} class" 130 | log_event(loggers=loggers, msg=msg, level='deb') 131 | 132 | if settings.params.allowed_pool: 133 | nets = [net.with_prefixlen for net in settings.params.allowed_pool] 134 | msg = f"Allowed hosts: " \ 135 | f"{', '.join(nets)}" 136 | log_event(loggers=loggers, msg=msg, level=level) 137 | 138 | msg = 'Server is now ready to answer requests' 139 | log_event(loggers=loggers, msg=msg, level='deb') 140 | 141 | ip, port = settings.params.server['host'], settings.params.server['port'] 142 | msg = f'Sylk Pushserver listening on http://{ip}:{port}' 143 | log_event(loggers=loggers, msg=msg, level='info') 144 | 145 | await asyncio.sleep(wait_for) 146 | 147 | return start_server 148 | 149 | 150 | server = get_server() 151 | -------------------------------------------------------------------------------- /scripts/sylk-pushclient-v2: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import re 4 | import sys 5 | import requests 6 | 7 | # try: 8 | # import pymysql 9 | # except ImportError: 10 | # pass 11 | 12 | from argparse import ArgumentParser 13 | 14 | 15 | if __name__ == '__main__': 16 | parser = ArgumentParser() 17 | subparsers = parser.add_subparsers(dest='action') 18 | parser.add_argument('--url', dest='url', required=False, default='http://localhost:8400', help='Base push URL') 19 | parser.add_argument('--account', dest='account', required=True, help='Account') 20 | 21 | subparserA = subparsers.add_parser('push', help='Send push request') 22 | subparserA.add_argument('--mediatype', dest='media_type', default="audio", required=False, help='Audio, Video or Message') 23 | subparserA.add_argument('--callid', dest='call_id', required=True, help='Call ID') 24 | subparserA.add_argument('--event', dest='event', required=False, help='Event', default='incoming_session') 25 | subparserA.add_argument('--from', dest='from_uri', required=True, help='From') 26 | subparserA.add_argument('--from_name', dest='from_name', required=False, help='From name') 27 | subparserA.add_argument('--to', dest='to_uri', required=True, help='To') 28 | subparserA.add_argument('--reason', dest='reason', required=False, help='Reason') 29 | subparserA.add_argument('--badge', dest='badge', default=1, required=False, help='Badge to display') 30 | subparserA.add_argument('--deviceid', dest='device_id', default=None, required=False, help='Device Id/Sip instance') 31 | subparserA.add_argument('--filename', dest='filename', default=None, required=False, help='Filetype') 32 | subparserA.add_argument('--filetype', dest='filetype', default=None, required=False, help='Filetype') 33 | 34 | subparserB = subparsers.add_parser('add', help='Add a push token') 35 | subparserB.add_argument('--platform', dest='platform', help='Platform') 36 | subparserB.add_argument('--appid', dest='appid', required=True, help='App ID') 37 | subparserB.add_argument('--token', dest='device_token', required=True, help='Device token') 38 | subparserB.add_argument('--deviceid', dest='device_id', required=True, help='Device Id') 39 | subparserB.add_argument('--silent', dest='silent', default="1", required=False, help='Silent') 40 | subparserB.add_argument('--user_agent', dest='user_agent', default="None", required=False, help='User Agent') 41 | 42 | subparserC = subparsers.add_parser('remove', help='Remove a push token') 43 | subparserC.add_argument('--appid', dest='appid', required=True, help='App ID') 44 | subparserC.add_argument('--deviceid', dest='device_id', required=True, help='Device Id') 45 | 46 | options = parser.parse_args() 47 | try: 48 | from_uri = re.sub(r'^"|"$', '', options.from_uri) 49 | except AttributeError: 50 | pass 51 | try: 52 | from_name = options.from_name.strip('\"') if options.from_name else None 53 | except AttributeError: 54 | pass 55 | 56 | try: 57 | media_type = options.media_type 58 | 59 | if ("video" in options.media_type): 60 | media_type = 'video' 61 | elif ("audio" in options.media_type): 62 | media_type = 'audio' 63 | except AttributeError: 64 | pass 65 | 66 | if options.url[-1] == '/': 67 | options.url = options.url[:-1] 68 | 69 | url = '{}/{}/{}'.format(options.url, 'v2/tokens', options.account) 70 | 71 | if options.action == 'add': 72 | log_params = {'platform': options.platform, 73 | 'app-id': options.appid, 74 | 'token': options.device_token, 75 | 'device-id': options.device_id, 76 | 'silent': options.silent, 77 | 'user-agent': options.user_agent} 78 | elif options.action == 'remove': 79 | log_params = {'app-id': options.appid, 80 | 'device-id': options.device_id} 81 | else: 82 | log_params = {'media-type': media_type, 83 | 'event': options.event, 84 | 'from': from_uri, 85 | 'from-display-name': from_name or from_uri, 86 | 'to': options.to_uri, 87 | 'call-id': options.call_id, 88 | 'badge': options.badge, 89 | 'reason': options.reason, 90 | 'filename': options.filename, 91 | 'filetype': options.filetype} 92 | if options.device_id is None: 93 | url = '{}/{}/{}/push'.format(options.url, 'v2/tokens', options.account) 94 | else: 95 | url = '{}/{}/{}/push/{}'.format(options.url, 'v2/tokens', options.account, options.device_id) 96 | 97 | def getMethod(*args, **kwargs): 98 | if options.action == 'remove': 99 | return requests.delete(*args, **kwargs) 100 | else: 101 | return requests.post(*args, **kwargs) 102 | 103 | action = options.action.title() 104 | try: 105 | r = getMethod(url, timeout=5, json=log_params) 106 | print("%s request to %s - %s: %s" % (action, url, r.status_code, r.text)) 107 | if r.status_code >= 200 and r.status_code < 300: 108 | sys.exit(0) 109 | elif r.status_code == 410: 110 | body = r.json() 111 | try: 112 | for result in body['data']: 113 | 114 | failure = result['body']['_content']['failure'] 115 | if failure == 1: 116 | # A push client may want to act based on various response codes 117 | # https://firebase.google.com/docs/cloud-messaging/http-server-ref#error-codes 118 | reason = result['body']['_content']['results'][0]['error'] 119 | if reason == 'NotRegistered': 120 | # print("Token %s must be purged" % token) 121 | # q = "delete from push_tokens where token = '%s'" % token 122 | # con = pymysql.connect('localhost', 'opensips', 'XYZ', 'opensips') 123 | # with con: 124 | # cur = con.cursor() 125 | # cur.execute(q) 126 | except KeyError: 127 | pass 128 | 129 | sys.exit(0) 130 | else: 131 | print("%s request to %s failed: %d: %s" % (action, url, r.status_code, r.text)) 132 | sys.exit(1) 133 | except Exception as e: 134 | print("%s request to %s failed: connection error" % (action, url)) 135 | sys.exit(1) 136 | -------------------------------------------------------------------------------- /scripts/sylk-pushserver-db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import sys 4 | import os 5 | 6 | from application import log 7 | from application.process import process 8 | 9 | CASSANDRA_MODULES_AVAILABLE = False 10 | try: 11 | from cassandra.cqlengine import columns, connection 12 | except ImportError: 13 | pass 14 | else: 15 | try: 16 | from cassandra.cqlengine.models import Model 17 | except ImportError: 18 | pass 19 | else: 20 | CASSANDRA_MODULES_AVAILABLE = True 21 | from cassandra import InvalidRequest 22 | from cassandra.cqlengine.query import LWTException 23 | from cassandra.cluster import Cluster, Session, NoHostAvailable, ExecutionProfile, EXEC_PROFILE_DEFAULT 24 | from cassandra.io import asyncioreactor 25 | from cassandra.policies import DCAwareRoundRobinPolicy 26 | from cassandra.cqlengine.management import sync_table, create_keyspace_simple 27 | 28 | parser = argparse.ArgumentParser(add_help=False) 29 | parser.add_argument('-h', '--help', 30 | action='help', 31 | default=argparse.SUPPRESS, 32 | help='Show this help message and exit.') 33 | 34 | parser.add_argument("--config_dir", 35 | default=None, 36 | metavar='PATH', 37 | help="Specify a config directory that contains " 38 | "general.ini, applications.ini and " 39 | "the credentials directory, " 40 | "Default it uses '/etc/sylk-pushserver'") 41 | 42 | options = parser.parse_args() 43 | 44 | 45 | class colors: 46 | if sys.stdout.isatty(): 47 | TGREEN = '\033[32m' 48 | ENDC = '\033[m' 49 | BOLD = '\033[1m' 50 | else: 51 | TGREEN = '' 52 | ENDC = '' 53 | BOLD = '' 54 | 55 | 56 | class parse_level: 57 | def format(record): 58 | if record.levelname != 'INFO': 59 | return f'{record.levelname:<8s} ' 60 | else: 61 | return f"{' ......':<8s} " 62 | 63 | 64 | def ask(question): 65 | try: 66 | while ( res:=input(colors.TGREEN + f"{'>>':>8s} {question} (Enter y/n) " + colors.ENDC).lower() ) not in {"y", "n"}: pass 67 | except KeyboardInterrupt: 68 | sys.exit(1) 69 | if res == "y": 70 | return True 71 | return False 72 | 73 | def bold(string): 74 | return colors.BOLD + string + colors.ENDC 75 | 76 | log.Formatter.prefix_format = parse_level 77 | 78 | if options.config_dir is not None: 79 | if not os.path.exists(f'{options.config_dir}'): 80 | log.info(f'Specified config directory {options.config_dir} does not exist') 81 | sys.exit(1) 82 | config_dir = options.config_dir 83 | 84 | process.configuration.local_directory = config_dir 85 | from pushserver.resources.storage.configuration import CassandraConfig, ServerConfig 86 | from pushserver.models.cassandra import PushTokens, OpenSips 87 | 88 | log.info(f"\n{' Sylk Pushserver - Cassandra database create/maintenance ':*^80s}\n") 89 | log.warn('Please note, this script can potentially destroy the data in the Cassandra database.') 90 | log.warn('Make sure you have a backup if you already have data in the Cassandra cluster') 91 | if not ask("Would you like to continue?"): 92 | sys.exit() 93 | 94 | os.environ['CQLENG_ALLOW_SCHEMA_MANAGEMENT'] = '1' 95 | 96 | configuration = CassandraConfig.__cfgtype__(CassandraConfig.__cfgfile__) 97 | if configuration.files: 98 | log.info('Reading storage configuration from {}'.format(', '.join(configuration.files))) 99 | 100 | if CassandraConfig.table: 101 | PushTokens.__table_name__ = CassandraConfig.table 102 | 103 | if CASSANDRA_MODULES_AVAILABLE: 104 | if CassandraConfig.cluster_contact_points: 105 | profile = ExecutionProfile( 106 | load_balancing_policy=DCAwareRoundRobinPolicy(), 107 | request_timeout=60 108 | ) 109 | cluster = Cluster(CassandraConfig.cluster_contact_points, protocol_version=4, execution_profiles={EXEC_PROFILE_DEFAULT: profile}) 110 | try: 111 | session = cluster.connect() 112 | except NoHostAvailable as e: 113 | log.warning("Can't connect to Cassandra cluster") 114 | sys.exit() 115 | else: 116 | connection.set_session(session) 117 | if CassandraConfig.keyspace in cluster.metadata.keyspaces: 118 | log.info(f"Keyspace {bold(CassandraConfig.keyspace)} is already on the server.") 119 | else: 120 | log.warning(f"Keyspace {bold(CassandraConfig.keyspace)} is {bold('not')} defined on the server") 121 | if ask("Would you like to create the keyspace with SimpleStrategy?"): 122 | create_keyspace_simple(CassandraConfig.keyspace, 1) 123 | else: 124 | sys.exit(1) 125 | 126 | keyspace = cluster.metadata.keyspaces[CassandraConfig.keyspace] 127 | replication_strategy = keyspace.replication_strategy 128 | log.info(f'Server has keyspace {bold(keyspace.name)} with replication strategy: {keyspace.replication_strategy.name}') 129 | 130 | # if replication_strategy.name == 'NetworkTopologyStrategy': 131 | # for dc in replication_strategy.dc_replication_factors_info: 132 | # log.info(f'DC: {dc}') 133 | 134 | PushTokens.__keyspace__ = CassandraConfig.keyspace 135 | OpenSips.__keyspace__ = CassandraConfig.keyspace 136 | if CassandraConfig.table in cluster.metadata.keyspaces[CassandraConfig.keyspace].tables: 137 | log.info(f'Table {bold(CassandraConfig.table)} is in the keyspace {bold(CassandraConfig.keyspace)}') 138 | 139 | if ask("Would you like to update the schema with the model?"): 140 | sync_table(PushTokens) 141 | else: 142 | log.info(f'Table {bold(CassandraConfig.table)} is not the keyspace {bold(CassandraConfig.keyspace)}') 143 | 144 | if ask ("Would you like to create the table from the model?"): 145 | sync_table(PushTokens) 146 | 147 | if 'mobile_devices' in cluster.metadata.keyspaces[CassandraConfig.keyspace].tables: 148 | log.info(f"The {bold('mobile_devices')} table is in keyspace {bold(CassandraConfig.keyspace)}") 149 | if ask("Would you like to update the schema with the model?"): 150 | sync_table(OpenSips) 151 | else: 152 | log.warn(f"The {bold('mobile_devices')} table is {bold('not')} in keyspace {bold(CassandraConfig.keyspace)}") 153 | if ask("Would you like to create 'mobile-devices' table from the model?"): 154 | sync_table(OpenSips) 155 | else: 156 | log.warning("Cassandra cluster contact points are not set, please adjust 'general.ini'") 157 | sys.exit() 158 | else: 159 | log.warning('The python Cassandra drivers are not installed, please make sure they are installed') 160 | sys.exit() 161 | -------------------------------------------------------------------------------- /pushserver/resources/storage/storage.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from collections import defaultdict 4 | 5 | import _pickle as pickle 6 | from application.python.types import Singleton 7 | from application.system import makedirs 8 | 9 | from pushserver.resources import settings 10 | from pushserver.resources.utils import log_event 11 | 12 | from .configuration import CassandraConfig, ServerConfig 13 | from .errors import StorageError 14 | 15 | __all__ = 'TokenStorage' 16 | 17 | 18 | CASSANDRA_MODULES_AVAILABLE = False 19 | try: 20 | from cassandra.cqlengine import columns, connection 21 | except ImportError: 22 | pass 23 | else: 24 | try: 25 | from cassandra.cqlengine.models import Model 26 | except ImportError: 27 | pass 28 | else: 29 | CASSANDRA_MODULES_AVAILABLE = True 30 | from cassandra import InvalidRequest 31 | from cassandra.cluster import NoHostAvailable 32 | from cassandra.cqlengine import CQLEngineException 33 | from cassandra.cqlengine.query import LWTException 34 | try: 35 | from cassandra.io import asyncioreactor 36 | except ImportError: 37 | pass 38 | from cassandra.policies import DCAwareRoundRobinPolicy 39 | 40 | from pushserver.models.cassandra import OpenSips, PushTokens 41 | if CassandraConfig.table: 42 | PushTokens.__table_name__ = CassandraConfig.table 43 | 44 | 45 | class FileStorage(object): 46 | def __init__(self): 47 | self._tokens = defaultdict() 48 | 49 | def _save(self): 50 | with open(os.path.join(ServerConfig.spool_dir.normalized, 'webrtc_device_tokens'), 'wb+') as f: 51 | pickle.dump(self._tokens, f) 52 | 53 | def load(self): 54 | try: 55 | tokens = pickle.load(open(os.path.join(ServerConfig.spool_dir, 'webrtc_device_tokens'), 'rb')) 56 | except Exception: 57 | pass 58 | else: 59 | self._tokens.update(tokens) 60 | 61 | def __getitem__(self, key): 62 | try: 63 | return self._tokens[key] 64 | except KeyError: 65 | return {} 66 | 67 | def add(self, account, contact_params): 68 | token = contact_params.token 69 | background_token = None 70 | if contact_params.platform == 'apple': 71 | try: 72 | (token, background_token) = contact_params.token.split('-', 1) 73 | except ValueError: 74 | pass 75 | 76 | contact_params.device_id = contact_params.device_id.strip("") 77 | 78 | data = contact_params.__dict__ 79 | data['token'] = token 80 | data['background_token'] = background_token 81 | 82 | key = f'{contact_params.app_id}-{contact_params.device_id}' 83 | if account in self._tokens: 84 | self._tokens[account][key] = data 85 | else: 86 | self._tokens[account] = {key: data} 87 | self._save() 88 | 89 | def remove(self, account, app_id, device_id): 90 | key = f'{app_id}-{device_id}' 91 | try: 92 | del self._tokens[account][key] 93 | except KeyError: 94 | pass 95 | self._save() 96 | 97 | 98 | class CassandraStorage(object): 99 | def load(self): 100 | connection_args = dict( 101 | load_balancing_policy=DCAwareRoundRobinPolicy(), 102 | protocol_version=4 103 | ) 104 | # try: 105 | # connection_args['connection_class'] = asyncioreactor.AsyncioConnection 106 | # except NameError: 107 | # pass 108 | 109 | try: 110 | connection.setup(CassandraConfig.cluster_contact_points, CassandraConfig.keyspace, **connection_args) 111 | except OSError: 112 | pass 113 | except NoHostAvailable as e: 114 | msg='Not able to connect to any of the Cassandra contact points' 115 | log_event(loggers=settings.params.loggers, msg=msg, level='error') 116 | 117 | def __getitem__(self, key): 118 | def query_tokens(key): 119 | username, domain = key.split('@', 1) 120 | tokens = {} 121 | try: 122 | for device in PushTokens.objects(PushTokens.username == username, PushTokens.domain == domain): 123 | tokens[f'{device.app_id}-{device.device_id}'] = {'device_id': device.device_id, 'token': device.device_token, 124 | 'background_token': device.background_token, 'platform': device.platform, 125 | 'app_id': device.app_id, 'silent': bool(int(device.silent))} 126 | except CQLEngineException as e: 127 | log_event(loggers=settings.params.loggers, msg=f'Get token(s) failed: {e}', level='error') 128 | raise StorageError 129 | return tokens 130 | return query_tokens(key) 131 | 132 | def add(self, account, contact_params): 133 | username, domain = account.split('@', 1) 134 | 135 | token = contact_params.token 136 | background_token = None 137 | if contact_params.platform == 'apple': 138 | try: 139 | (token, background_token) = contact_params.token.split('-', 1) 140 | except ValueError: 141 | pass 142 | 143 | contact_params.device_id = contact_params.device_id.strip("") 144 | 145 | try: 146 | PushTokens.create(username=username, domain=domain, device_id=contact_params.device_id, 147 | device_token=token, background_token=background_token, platform=contact_params.platform, 148 | silent=str(int(contact_params.silent is True)), app_id=contact_params.app_id, 149 | user_agent=contact_params.user_agent) 150 | except (CQLEngineException, InvalidRequest) as e: 151 | log_event(loggers=settings.params.loggers, msg=f'Storing token failed: {e}', level='error') 152 | raise StorageError 153 | try: 154 | OpenSips.create(opensipskey=account, opensipsval='1') 155 | except (CQLEngineException, InvalidRequest) as e: 156 | log_event(loggers=settings.params.loggers, msg=e, level='error') 157 | raise StorageError 158 | 159 | def remove(self, account, app_id='', device_id=''): 160 | username, domain = account.split('@', 1) 161 | try: 162 | PushTokens.objects(PushTokens.username == username, PushTokens.domain == domain, PushTokens.device_id == device_id, PushTokens.app_id == app_id).if_exists().delete() 163 | except LWTException: 164 | pass 165 | 166 | # We need to check for other device_ids/app_ids before we can remove the cache value for OpenSIPS 167 | if not self[account]: 168 | try: 169 | OpenSips.objects(OpenSips.opensipskey == account).if_exists().delete() 170 | except LWTException: 171 | pass 172 | 173 | 174 | class TokenStorage(object, metaclass=Singleton): 175 | 176 | def __new__(self): 177 | configuration = CassandraConfig.__cfgtype__(CassandraConfig.__cfgfile__) 178 | if configuration.files: 179 | msg = 'Reading storage configuration from {}'.format(', '.join(configuration.files)) 180 | log_event(loggers=settings.params.loggers, msg=msg, level='info') 181 | makedirs(ServerConfig.spool_dir.normalized) 182 | if CASSANDRA_MODULES_AVAILABLE and CassandraConfig.cluster_contact_points: 183 | if CassandraConfig.debug: 184 | logging.getLogger('cassandra').setLevel(logging.DEBUG) 185 | else: 186 | logging.getLogger('cassandra').setLevel(logging.INFO) 187 | log_event(loggers=settings.params.loggers, msg='Using Cassandra for token storage', level='info') 188 | return CassandraStorage() 189 | else: 190 | log_event(loggers=settings.params.loggers, msg='Using pickle file for token storage', level='info') 191 | return FileStorage() 192 | -------------------------------------------------------------------------------- /pushserver/pns/base.py: -------------------------------------------------------------------------------- 1 | import concurrent 2 | import datetime 3 | import json 4 | import socket 5 | 6 | import requests 7 | 8 | from pushserver.resources.utils import log_event 9 | 10 | 11 | class PNS(object): 12 | """ 13 | Push Notification Service 14 | """ 15 | 16 | def __init__(self, app_id: str, app_name: str, url_push: str, voip: bool = False): 17 | """ 18 | :param app_id: `str`, Id provided by application. 19 | :param app_name: `str`, Application name. 20 | :param url_push: `str`, URI to push a notification. 21 | :param voip: `bool`, Required for apple, `True` for voip push notification type. 22 | """ 23 | self.app_id = app_id 24 | self.app_name = app_name 25 | self.url_push = url_push 26 | self.voip = voip 27 | 28 | 29 | class PlatformRegister(object): 30 | def __init__(self, config_dict, credentials_path: str, loggers: dict): 31 | 32 | self.credentials_path = credentials_path 33 | self.config_dict = config_dict 34 | self.loggers = loggers 35 | 36 | 37 | class PushRequest(object): 38 | 39 | def __init__(self, error: str, app_name: str, app_id: str, platform: str, 40 | request_id: str, headers: str, payload: dict, token: str, 41 | media_type: str, loggers: dict, log_remote: dict, wp_request: dict): 42 | 43 | self.error = error 44 | self.app_name = app_name 45 | self.app_id = app_id 46 | self.platform = platform 47 | self.request_id = request_id 48 | self.headers = headers 49 | self.payload = payload 50 | self.token = token 51 | self.media_type = media_type 52 | self.loggers = loggers 53 | self.log_remote = log_remote 54 | self.wp_request = wp_request 55 | 56 | results = {} 57 | 58 | def retries_params(self, media_type: str) -> tuple: 59 | if not media_type or media_type == 'sms': 60 | n_tries = 11 61 | else: 62 | n_tries = 7 63 | bo_factor = 0.5 64 | 65 | return n_tries, bo_factor 66 | 67 | def log_request(self, path: str) -> None: 68 | """ 69 | Write in log information about push notification, 70 | using log_event function 71 | 72 | :param path: `str`, path where push notification will be sent. 73 | :param app_name: `str` for friendly log. 74 | :param platform: `str`, 'apple' or 'firebase'. 75 | :param request_id: `str`, request ID generated on request event. 76 | :param headers: `json`, of push notification. 77 | :param payload: `json`, of push notification. 78 | :param loggers: `dict` global logging instances to write messages (params.loggers) 79 | """ 80 | 81 | # log_app_name = app_name.capitalize() 82 | log_platform = self.platform.capitalize() 83 | 84 | log_path = path if path else self.path 85 | 86 | level = 'info' 87 | msg = f'outgoing {log_platform} request {self.request_id} to {log_path}' 88 | log_event(loggers=self.loggers, msg=msg, level=level) 89 | 90 | msg = f'outgoing {log_platform} request {self.request_id} headers: {self.headers}' 91 | log_event(loggers=self.loggers, msg=msg, level='deb') 92 | 93 | msg = f'outgoing {log_platform} request {self.request_id} body: {self.payload}' 94 | log_event(loggers=self.loggers, msg=msg, level='deb') 95 | 96 | def log_error(self): 97 | level = 'error' 98 | msg = f"outgoing {self.platform.title()} response for " \ 99 | f"{self.request_id}, push failed: " \ 100 | f"{self.error}" 101 | log_event(loggers=self.loggers, msg=msg, level=level) 102 | 103 | def server_ip(self, destination): 104 | try: 105 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 106 | s.connect((destination, 1)) 107 | return s.getsockname()[0] 108 | except socket.error: 109 | return None 110 | 111 | def log_remotely(self, body: dict, code: str, reason: str, url: str) -> None: 112 | """ 113 | Fork a log of a payload incoming request to a remote url 114 | :param body: `dict` response to push request 115 | :param code: `int` of response to push request 116 | :param reason: `str` of response to push request 117 | """ 118 | 119 | push_response = {'code': code, 'description': reason, 'push_url': url} 120 | headers = {'Content-Type': 'application/json'} 121 | server_ip = self.server_ip('1.2.3.4') 122 | now = datetime.datetime.now() 123 | timestamp = now.strftime("%Y-%m-%d %H:%M:%S") 124 | payload = {'request': body, 'response': push_response, 125 | 'server_ip': server_ip,'timestamp': timestamp} 126 | 127 | task = 'log remote' 128 | 129 | log_key = self.log_remote.get('log_key') 130 | log_time_out = self.log_remote.get('log_time_out') 131 | 132 | results = [] 133 | 134 | for log_url in self.log_remote['log_urls']: 135 | msg = f'{task} request {self.request_id} to {log_url}' 136 | log_event(loggers=self.loggers, msg=msg, level='deb') 137 | msg = f'{task} request {self.request_id} to {log_url} headers: {headers}' 138 | log_event(loggers=self.loggers, msg=msg, level='deb') 139 | msg = f'{task} request {self.request_id} to {log_url} body: {payload}' 140 | log_event(loggers=self.loggers, msg=msg, level='deb') 141 | 142 | try: 143 | with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: 144 | futures = [ 145 | executor.submit( 146 | lambda: requests.post(url=log_url, 147 | json=payload, 148 | headers=headers, 149 | timeout=log_time_out or 2) 150 | ) 151 | for log_url in self.log_remote['log_urls'] 152 | ] 153 | 154 | results = [ 155 | f.result() 156 | for f in futures 157 | ] 158 | except requests.exceptions.ConnectionError as exc: 159 | msg = f'{task} for {self.request_id}: connection error {exc}' 160 | log_event(loggers=self.loggers, msg=msg, level='error') 161 | except requests.exceptions.ReadTimeout as exc: 162 | msg = f'{task} for {self.request_id}: connection error {exc}' 163 | log_event(loggers=self.loggers, msg=msg, level='error') 164 | 165 | if not results: 166 | return 167 | 168 | for url, result in list(zip(self.log_remote['log_urls'], results)): 169 | code = result.status_code 170 | text = result.text[:500] 171 | 172 | if log_key: 173 | try: 174 | result = result.json() 175 | value = result.get(log_key) 176 | except (json.decoder.JSONDecodeError, AttributeError): 177 | value = {} 178 | 179 | if value: 180 | 181 | msg = f'{task} response for request {self.request_id} from {url} - ' \ 182 | f'{code} {log_key}: {value}' 183 | log_event(loggers=self.loggers, msg=msg, level='deb') 184 | else: 185 | msg = f'{task} response for request {self.request_id} - ' \ 186 | f'code: {code}, key not found' 187 | log_event(loggers=self.loggers, msg=msg, level='error') 188 | else: 189 | msg = f'{task} response for request {self.request_id} ' \ 190 | f'from {url}: {code} {text}' 191 | log_event(loggers=self.loggers, msg=msg, level='deb') 192 | 193 | def log_results(self): 194 | """ 195 | Log to journal system the result of push notification 196 | """ 197 | body = self.results['body'] 198 | code = self.results['code'] 199 | reason = self.results['reason'] 200 | url = self.results['url'] 201 | 202 | level = 'info' 203 | body = json.dumps(body) 204 | msg = f"outgoing {self.platform.title()} response for request " \ 205 | f"{self.request_id} body: {body}" 206 | log_event(loggers=self.loggers, msg=msg, level='deb') 207 | 208 | if code == 200: 209 | msg = f"outgoing {self.platform.title()} response for request " \ 210 | f"{self.request_id}: push notification sent successfully" 211 | log_event(loggers=self.loggers, msg=msg, level=level) 212 | else: 213 | msg = f"outgoing {self.platform.title()} response for " \ 214 | f"{self.request_id}, push failed with code {code}: {reason}" 215 | log_event(loggers=self.loggers, msg=msg, level='error') 216 | 217 | body = {'incoming_body': self.wp_request.__dict__, 218 | 'outgoing_headers': self.headers, 219 | 'outgoing_body': self.payload 220 | } 221 | 222 | if self.log_remote.get('log_urls'): 223 | self.log_remotely(body=body, code=code, reason=reason, url=url) 224 | -------------------------------------------------------------------------------- /pushserver/pns/register.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import importlib 3 | import os 4 | import sys 5 | 6 | from pushserver.resources.utils import log_event 7 | 8 | 9 | def check_apps_classes(name: str, platform: str, extra_dir: str) -> tuple: 10 | """ 11 | Check for custom classes 12 | :param name: `str` name of custom app, which corresponds to module 13 | :param platform: `str` 'apple' or 'firebase' 14 | :param extra_dir: `str` path to extra applications dir 15 | :return: a tuple with, error (str), headers_class (class), payload_class (class) 16 | """ 17 | 18 | # Check for known apps: 19 | try: 20 | module = importlib.import_module(f'pushserver.applications.{name.lower()}') 21 | headers_class_name = f'{platform.capitalize()}{name.lower().capitalize()}Headers' 22 | headers_class = getattr(module, headers_class_name) 23 | 24 | payload_class_name = f'{platform.capitalize()}{name.lower().capitalize()}Payload' 25 | payload_class = getattr(module, payload_class_name) 26 | except ModuleNotFoundError: 27 | headers_class, payload_class = None, None 28 | 29 | if headers_class and payload_class: 30 | return '', headers_class, payload_class 31 | 32 | if not extra_dir: 33 | if os.path.isdir('/etc/sylk-pushserver'): 34 | extra_dir = '/etc/sylk-pushserver/applications' 35 | else: 36 | current_dir = os.getcwd() 37 | extra_dir = current_dir + "/config/applications" 38 | 39 | if os.path.isdir(extra_dir): 40 | sys.path.append(extra_dir) 41 | 42 | error = '' 43 | 44 | try: 45 | module = importlib.import_module(name.lower()) 46 | try: 47 | headers_class_name = f'{platform.capitalize()}{name.lower().capitalize()}Headers' 48 | headers_class = getattr(module, headers_class_name) 49 | try: 50 | payload_class_name = f'{platform.capitalize()}{name.lower().capitalize()}Payload' 51 | payload_class = getattr(module, payload_class_name) 52 | except AttributeError: 53 | error = f'{platform.capitalize()}{name.lower().capitalize()}Payload class not found ' \ 54 | f'in {name.lower()}' 55 | headers_class, payload_class = None, None 56 | except AttributeError: 57 | error = f'{platform.capitalize()}{name.lower().capitalize()}Headers class not found ' \ 58 | f'in {name.lower()}' 59 | headers_class, payload_class = None, None 60 | except ModuleNotFoundError: 61 | error = f'{name.lower()} module not found' 62 | headers_class, payload_class = None, None 63 | 64 | return error, headers_class, payload_class 65 | 66 | 67 | def check_pns_classes(platform: str, extra_dir: str) -> tuple: 68 | """ 69 | Check for custom classes 70 | :param name: `str` name of custom app, which corresponds to module 71 | :param platform: `str` 'apple' or 'firebase' 72 | :param extra_dir: `str` path to extra applications dir 73 | :return: a tuple with, error (str), headers_class (class), payload_class (class) 74 | """ 75 | 76 | # Check for known apps: 77 | try: 78 | register_module = importlib.import_module(f'pushserver.pns.{platform}') 79 | register_class = getattr(register_module, f'{platform.capitalize()}Register') 80 | pns_class = getattr(register_module, f'{platform.capitalize()}PNS') 81 | except ModuleNotFoundError: 82 | register_class = None 83 | 84 | if register_class: 85 | return '', register_class 86 | 87 | if not extra_dir: 88 | if os.path.isdir('/etc/sylk-pushserver'): 89 | extra_dir = '/etc/sylk-pushserver/pns' 90 | else: 91 | current_dir = os.getcwd() 92 | extra_dir = current_dir + "/config/pns" 93 | 94 | if os.path.isdir(extra_dir): 95 | sys.path.append(extra_dir) 96 | 97 | error = '' 98 | 99 | try: 100 | register_module = importlib.import_module(platform.lower()) 101 | try: 102 | register_class = getattr(register_module, f'{platform.capitalize()}Register') 103 | pns_class = getattr(register_module, f'{platform.capitalize()}PNS') 104 | except AttributeError: 105 | error = f'{platform.capitalize()}PNS class not found ' \ 106 | f'in {platform.lower()}' 107 | register_class = None 108 | except ModuleNotFoundError: 109 | error = f'{platform.lower()} module not found in pushserver/pns or {extra_dir}' 110 | register_class = None 111 | 112 | return error, register_class 113 | 114 | 115 | def get_pns_from_config(config_path: str, credentials: str, apps_extra_dir: str, 116 | pns_extra_dir: str, loggers: dict) -> dict: 117 | """ 118 | Create a dictionary with applications with their own PN server address, certificates and keys 119 | :param config_path: `str` path to config file (see config.ini.example) 120 | :param credentials: `str` path to credentials dir 121 | :param apps_extra_dir: `str` path to extra applications dir 122 | :param pns_extra_dir: `str` path to extra pns dir 123 | :param loggers: `dict` global logging instances to write messages (params.loggers) 124 | """ 125 | config = configparser.ConfigParser() 126 | config.read(config_path) 127 | # pns_dict = {(, 'apple')): {'id': str, 128 | # 'name': str, 129 | # 'headers_class': headers_class, 130 | # 'payload_class': payload_class, 131 | # 'pns': ApplePNS, 132 | # 'conn': AppleConn} 133 | # (, 'firebase'): {'id': str, 134 | # 'name': str, 135 | # 'headers_class': headers_class, 136 | # 'payload_class': payload_class, 137 | # 'pns': FirebasePNS} 138 | # ( ... 139 | # } 140 | 141 | pns_register = {} 142 | invalid_apps = {} 143 | for id in config.sections(): 144 | app_id = config[id]['app_id'] 145 | name = config[id]['app_type'].lower() 146 | platform = config[id]['app_platform'].lower() 147 | voip = config[id].get('voip') 148 | error, log, log_urls, log_key, log_timeout = '', False, '', '', None 149 | try: 150 | log_urls_str = config[id]['log_remote_urls'] 151 | log_urls = set(log_urls_str.split(',')) 152 | log_key = config[id].get('log_key') 153 | log_timeout = config[id].get('log_time_out') 154 | log_timeout = int(log_timeout) if log_timeout else None 155 | except KeyError: 156 | log = False 157 | except SyntaxError: 158 | error = f'log_remote_urls = {log_urls_str} - bad syntax' 159 | log = False 160 | log_remote = {'error': error, 161 | 'log_urls': log_urls, 162 | 'log_remote_key': log_key, 163 | 'log_remote_timeout': log_timeout} 164 | 165 | if voip: 166 | voip = True if voip.lower() == 'true' else False 167 | 168 | error, register_class = check_pns_classes(platform=platform, extra_dir=pns_extra_dir) 169 | 170 | if error: 171 | reason = error 172 | invalid_apps[(app_id, platform)] = {'name': name, 'reason': reason} 173 | continue 174 | 175 | register = register_class(app_id=app_id, 176 | app_name=name, 177 | voip=voip, 178 | config_dict=config[id], 179 | credentials_path=credentials, 180 | loggers=loggers) 181 | register_entries = register.register_entries 182 | error = register.error 183 | 184 | if error: 185 | reason = error 186 | invalid_apps[(app_id, platform)] = {'name': name, 'reason': reason} 187 | continue 188 | 189 | error, \ 190 | headers_class, \ 191 | payload_class = check_apps_classes(name, 192 | platform, 193 | apps_extra_dir) 194 | 195 | if error: 196 | reason = error 197 | invalid_apps[(app_id, platform)] = {'name': name, 198 | 'reason': reason} 199 | continue 200 | 201 | pns_register[(app_id, platform)] = {'id': id, 202 | 'name': name, 203 | 'headers_class': headers_class, 204 | 'payload_class': payload_class, 205 | 'log_remote': log_remote} 206 | 207 | for k, v in register_entries.items(): 208 | pns_register[(app_id, platform)][k] = v 209 | 210 | pnses = [] 211 | for app in pns_register.keys(): 212 | pnses.append(pns_register[app]['pns'].__class__.__name__) 213 | pnses = set(pnses) 214 | 215 | return {'pns_register': pns_register, 216 | 'invalid_apps': invalid_apps, 217 | 'pnses': pnses} 218 | -------------------------------------------------------------------------------- /pushserver/models/requests.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, root_validator, validator 2 | 3 | from pushserver.resources import settings 4 | from pushserver.resources.utils import fix_platform_name 5 | 6 | 7 | def gen_validator_items() -> tuple: 8 | """ 9 | Generate some dicts according to minimum required parameters, 10 | and each app required paramaters, usefull for request validation. 11 | :return: two `dict` objects with common items and apps items. 12 | """ 13 | 14 | common_items = {'app-id', 'call-id', 15 | 'platform', 'from', 'token'} 16 | 17 | only_sylk_items = {'silent', 'to', 'event'} 18 | only_linphone_items = set() 19 | 20 | apps_items = {'sylk': common_items | only_sylk_items, # union 21 | 'linphone': common_items | only_linphone_items} 22 | 23 | return common_items, apps_items 24 | 25 | 26 | common_items, apps_items = gen_validator_items() 27 | 28 | 29 | def alias_rename(attribute: str) -> str: 30 | """ 31 | Rename request name attribute, replacing '_' by '_' 32 | and removing 'sip_' characters. 33 | :param attribute: `str` from request 34 | :return: a `str` corresponding to alias. 35 | """ 36 | if attribute.startswith('sip_'): 37 | return attribute.split('_', maxsplit=1)[1] 38 | return attribute.replace('_', '-') 39 | 40 | 41 | class AddRequest(BaseModel): 42 | app_id: str # id provided by the mobile application (bundle id) 43 | platform: str # 'firebase', 'android', 'apple' or 'ios' 44 | token: str # destination device token in hex 45 | device_id: str # the device-id that owns the token (used for logging purposes) 46 | silent: bool = True 47 | user_agent: str = None 48 | 49 | class Config: 50 | alias_generator = alias_rename 51 | 52 | @root_validator(pre=True) 53 | def check_required_items_for_add(cls, values): 54 | 55 | app_id, platform = values.get('app-id'), values.get('platform') 56 | 57 | if not app_id: 58 | raise ValueError("Field 'app-id' required") 59 | if not platform: 60 | raise ValueError("Field 'platform' required") 61 | 62 | platform = fix_platform_name(platform) 63 | 64 | if platform not in ('firebase', 'apple'): 65 | raise ValueError(f"The '{platform}' platform is not configured") 66 | 67 | pns_register = settings.params.pns_register 68 | 69 | if (app_id, platform) not in pns_register.keys(): 70 | raise ValueError(f"{platform.capitalize()} {app_id} app " 71 | f"is not configured") 72 | return values 73 | 74 | @validator('platform') 75 | def platform_valid_values(cls, v): 76 | if v not in ('apple', 'ios', 'android', 'firebase', 'fcm', 'apns'): 77 | raise ValueError("platform must be 'apple', 'android' or 'firebase'") 78 | return v 79 | 80 | 81 | class AddResponse(BaseModel): 82 | app_id: str # id provided by the mobile application (bundle id) 83 | platform: str # 'firebase', 'android', 'apple' or 'ios' 84 | token: str # destination device token in hex 85 | device_id: str # the device-id that owns the token (used for logging purposes) 86 | silent: bool = True 87 | user_agent: str = None 88 | 89 | class Config: 90 | allow_population_by_field_name = True 91 | alias_generator = alias_rename 92 | 93 | 94 | class RemoveRequest(BaseModel): 95 | app_id: str # id provided by the mobile application (bundle id) 96 | device_id: str = None # the device-id that owns the token (used for logging purposes) 97 | 98 | class Config: 99 | alias_generator = alias_rename 100 | 101 | @root_validator(pre=True) 102 | def check_required_items_for_add(cls, values): 103 | 104 | app_id = values.get('app-id') 105 | 106 | if not app_id: 107 | raise ValueError("Field 'app-id' required") 108 | 109 | return values 110 | 111 | 112 | class RemoveResponse(BaseModel): 113 | app_id: str # id provided by the mobile application (bundle id) 114 | device_id: str = None # the device-id that owns the token (used for logging purposes) 115 | 116 | class Config: 117 | allow_population_by_field_name = True 118 | alias_generator = alias_rename 119 | 120 | 121 | class PushRequest(BaseModel): 122 | event: str = None # (required for sylk) 'incoming_session', 'incoming_conference' or 'cancel' 123 | call_id: str # (required for apple) unique sip parameter 124 | sip_from: str # (required for firebase) SIP URI for who is calling 125 | from_display_name: str = None # (required for sylk) display name of the caller 126 | to: str # SIP URI for who is called 127 | media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' 128 | reason: str = None # Cancel reason 129 | badge: int = 1 130 | filename: str = None 131 | filetype: str = None 132 | account: str = None 133 | content: str = None 134 | content_type: str = None 135 | 136 | class Config: 137 | alias_generator = alias_rename 138 | 139 | 140 | class WakeUpRequest(BaseModel): 141 | # API expects a json object like: 142 | app_id: str # id provided by the mobile application (bundle id) 143 | platform: str # 'firebase', 'android', 'apple' or 'ios' 144 | event: str = None # (required for sylk) 'incoming_session', 'incoming_conference', 'cancel' or 'message' 145 | token: str # destination device token in hex 146 | device_id: str = None # the device-id that owns the token (used for logging purposes) 147 | call_id: str # (required for apple) unique sip parameter 148 | sip_from: str # (required for firebase) SIP URI for who is calling 149 | from_display_name: str = None # (required for sylk) display name of the caller 150 | sip_to: str # SIP URI for who is called 151 | media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' 152 | silent: bool = True # True for silent notification 153 | reason: str = None # Cancel reason 154 | badge: int = 1 155 | filename: str = None 156 | filetype: str = None 157 | account: str = None 158 | content: str = None 159 | content_type: str = None 160 | 161 | class Config: 162 | alias_generator = alias_rename 163 | 164 | @root_validator(pre=True) 165 | def check_required_items_by_app(cls, values): 166 | 167 | app_id, platform = values.get('app-id'), values.get('platform') 168 | 169 | if not app_id: 170 | raise ValueError("Field 'app-id' required") 171 | if not platform: 172 | raise ValueError("Field 'platform' required") 173 | 174 | platform = fix_platform_name(platform) 175 | 176 | if platform not in ('firebase', 'apple'): 177 | raise ValueError(f"'{platform}' platform is not configured") 178 | 179 | pns_register = settings.params.pns_register 180 | 181 | if (app_id, platform) not in pns_register.keys(): 182 | raise ValueError(f"{platform.capitalize()} {app_id} app " 183 | f"is not configured") 184 | 185 | try: 186 | name = pns_register[(app_id, platform)]['name'] 187 | check_items = apps_items[name] 188 | missing_items = [] 189 | 190 | for item in check_items: 191 | if values.get(item) is None: 192 | missing_items.append(item) 193 | if missing_items: 194 | missing_items_show = [] 195 | for item in missing_items: 196 | if item in ('sip_to', 'sip_from', 'device_id'): 197 | item = item.split('_')[1] 198 | else: 199 | item = item.replace('-', '_') 200 | missing_items_show.append(item) 201 | 202 | raise ValueError(f"'{' ,'.join(missing_items)}' " 203 | f"item(s) missing.") 204 | except KeyError: 205 | pass 206 | 207 | event = values.get('event') 208 | if event != 'cancel': 209 | media_type = values.get('media-type') 210 | if not media_type: 211 | raise ValueError("Field media-type required") 212 | if media_type not in ('audio', 'video', 'image', 'chat', 'sms', 'file-transfer'): 213 | raise ValueError("media-type must be 'audio', 'video', " 214 | "'chat', 'sms', 'file-transfer', 'image'") 215 | if 'linphone' in name: 216 | if event: 217 | if event != 'incoming_session': 218 | raise ValueError('event not found (must be incoming_sesion)') 219 | else: 220 | values['event'] = 'incoming_session' 221 | return values 222 | 223 | @validator('platform') 224 | def platform_valid_values(cls, v): 225 | if v not in ('apple', 'ios', 'android', 'firebase', 'fcm', 'apns'): 226 | raise ValueError("platform must be 'apple', 'android' or 'firebase'") 227 | return v 228 | 229 | @validator('event') 230 | def event_valid_values(cls, v): 231 | if v not in ('incoming_session', 'incoming_conference_request', 'cancel', 'message', 'transfer'): 232 | raise ValueError("event must be 'incoming_session', 'incoming_conference_request', 'cancel' or 'message', 'transfer'") 233 | return v 234 | -------------------------------------------------------------------------------- /pushserver/applications/sylk.py: -------------------------------------------------------------------------------- 1 | # import datetime 2 | 3 | from pushserver.applications.apple import * 4 | from pushserver.applications.firebase import * 5 | from pushserver.resources.utils import callid_to_uuid 6 | 7 | # from firebase_admin import messaging 8 | 9 | 10 | __all__ = ['AppleSylkHeaders', 'AppleSylkPayload', 11 | 'FirebaseSylkHeaders', 'FirebaseSylkPayload'] 12 | 13 | 14 | class AppleSylkHeaders(AppleHeaders): 15 | """ 16 | An Apple headers structure for a push notification 17 | """ 18 | 19 | def create_push_type(self) -> str: 20 | """ 21 | logic to define apns_push_type value using request parameters 22 | apns_push_type reflect the contents of the notification’s payload, 23 | it can be: 24 | 'alert', 'background', 'voip', 25 | 'complication', 'fileprovider' or 'mdm'. 26 | """ 27 | push_type = 'alert' 28 | if self.event in ('incoming_session', 'incoming_conference_request'): 29 | push_type = 'voip' 30 | elif self.event == 'cancel': 31 | push_type = 'background' 32 | 33 | return push_type 34 | 35 | def create_expiration(self) -> int: 36 | """ 37 | logic to define apns_expiration value using request parameters 38 | apns_expiration is the date at which the notification expires, 39 | (UNIX epoch expressed in seconds UTC). 40 | """ 41 | return '120' 42 | 43 | def create_topic(self) -> str: 44 | """ 45 | logic to define apns_topic value using request parameters 46 | apns_topic is in general is the app’s bundle ID and may have 47 | a suffix based on the notification’s type. 48 | """ 49 | apns_topic = self.app_id 50 | 51 | if self.app_id.endswith('.dev') or self.app_id.endswith('.prod'): 52 | apns_topic = '.'.join(self.app_id.split('.')[:-1]) 53 | 54 | if self.event in ('incoming_session', 'incoming_conference_request'): 55 | apns_topic = f"{apns_topic}.voip" 56 | 57 | return apns_topic 58 | 59 | def create_priority(self) -> int: 60 | """ 61 | logic to define apns_priority value using request parameters 62 | Notification priority, 63 | apns_prioriy 10 o send the notification immediately, 64 | 5 to send the notification based on power considerations 65 | on the user’s device. 66 | """ 67 | apns_priority = '10' if self.event in ('incoming_session', 'incoming_conference_request') else '5' 68 | 69 | return apns_priority 70 | 71 | 72 | class FirebaseSylkHeaders(FirebaseHeaders): 73 | """ 74 | Firebase headers for a push notification 75 | """ 76 | 77 | 78 | class AppleSylkPayload(ApplePayload): 79 | """ 80 | A payload for a Apple Sylk push notification 81 | """ 82 | 83 | @property 84 | def payload(self) -> str: 85 | """ 86 | Generate an AppleSylk notification payload 87 | """ 88 | 89 | if self.event == 'cancel': 90 | payload = { 91 | 'event': self.event, 92 | 'call-id': self.call_id, 93 | 'session-id': callid_to_uuid(self.call_id), 94 | 'reason': self.reason 95 | } 96 | elif self.event == 'message': 97 | payload = { 98 | 'aps': { 99 | 'alert': { 100 | 'title': 'New message', 101 | 'body': 'From %s' % self.sip_from, 102 | }, 103 | 'message_id': self.call_id, 104 | "sound": "default", 105 | "content-available": 1, 106 | "badge": self.badge, 107 | }, 108 | 'data': { 109 | 'event': self.event, 110 | 'message_id': self.call_id, 111 | 'from_uri': self.sip_from, 112 | 'to_uri': self.sip_to, 113 | 'content': self.content, 114 | 'content_type': self.content_type 115 | } 116 | } 117 | elif self.event == 'transfer': 118 | payload = { 119 | 'aps': { 120 | 'alert': { 121 | 'title': f'New {self.media_type} message' if self.media_type in ['audio', 'video'] else 'New file', 122 | 'body': 'From %s' % self.sip_from, 123 | }, 124 | "sound": "default", 125 | "badge": self.badge, 126 | }, 127 | 'data': { 128 | 'event': self.event, 129 | 'from_uri': self.sip_from, 130 | 'to_uri': self.sip_to, 131 | 'file-id': self.call_id, 132 | 'media-type': self.media_type, 133 | 'metadata': { 134 | 'filename': self.filename, 135 | 'filetype': self.filetype 136 | } 137 | } 138 | } 139 | else: 140 | payload = { 141 | 'event': self.event, 142 | 'call-id': self.call_id, 143 | 'session-id': callid_to_uuid(self.call_id), 144 | 'media-type': self.media_type, 145 | 'from_uri': self.sip_from, 146 | 'from_display_name': self.from_display_name, 147 | 'to_uri': self.sip_to 148 | } 149 | if self.event in ('incoming_conference_request'): 150 | payload['account'] = self.account 151 | 152 | return payload 153 | 154 | 155 | class FirebaseSylkPayload(FirebasePayload): 156 | """ 157 | A payload for a Firebase Sylk push notification 158 | """ 159 | 160 | @property 161 | def payload(self) -> str: 162 | """ 163 | Generate a Sylk payload and extra Firebase parameters 164 | """ 165 | 166 | if not self.from_display_name: 167 | from_display_name = self.sip_from 168 | else: 169 | from_display_name = self.from_display_name 170 | 171 | if self.event == 'cancel': 172 | data = { 173 | 'event': self.event, 174 | 'call-id': self.call_id, 175 | 'session-id': callid_to_uuid(self.call_id), 176 | 'reason': self.reason 177 | } 178 | elif self.event == 'message': 179 | data = { 180 | 'event': self.event, 181 | 'message_id': self.call_id, 182 | 'from_uri': self.sip_from, 183 | 'to_uri': self.sip_to, 184 | 'content': self.content, 185 | 'content_type': self.content_type 186 | } 187 | elif self.event == 'transfer': 188 | data = { 189 | 'event': self.event, 190 | 'from_uri': self.sip_from, 191 | 'to_uri': self.sip_to, 192 | 'media-type': self.media_type, 193 | 'file-id': self.call_id, 194 | 'metadata': { 195 | 'filename': self.filename, 196 | 'filetype': self.filetype 197 | } 198 | } 199 | else: 200 | data = { 201 | 'event': self.event, 202 | 'call-id': self.call_id, 203 | 'session-id': callid_to_uuid(self.call_id), 204 | 'media-type': self.media_type, 205 | 'from_uri': self.sip_from, 206 | 'from_display_name': from_display_name, 207 | 'to_uri': self.sip_to 208 | } 209 | if self.event in ('incoming_conference_request'): 210 | data['account'] = self.account 211 | 212 | http_payload = { 213 | 'message': { 214 | 'token': self.token, 215 | 'data': data, 216 | 'android': { 217 | 'priority': 'high', 218 | 'ttl': '60s' 219 | } 220 | } 221 | } 222 | if self.event == 'message': 223 | http_payload = { 224 | 'message': { 225 | 'token': self.token, 226 | 'data': data, 227 | 'apns': { 228 | 'headers': { 229 | 'apns-priority': '5', 230 | } 231 | }, 232 | 'android': { 233 | 'priority': 'high', 234 | 'ttl': '60s', 235 | } 236 | } 237 | } 238 | elif self.event == 'transfer': 239 | http_payload = { 240 | 'message': { 241 | 'token': self.token, 242 | 'data': data, 243 | 'notification': { 244 | 'title': f'New {self.media_type} message' if self.media_type in ['audio', 'video'] else 'New file', 245 | 'body': 'From %s' % self.sip_from, 246 | 'image': 'https://icanblink.com/apple-touch-icon-180x180.png' 247 | }, 248 | 'apns': { 249 | 'headers': { 250 | 'apns-priority': '5', 251 | } 252 | }, 253 | 'android': { 254 | 'priority': 'high', 255 | 'ttl': '60s', 256 | 'notification': { 257 | 'channel_id': 'sylk-messages-sound', 258 | 'sound': 'default', 259 | 'default_sound': True, 260 | 'notification_priority': 'PRIORITY_HIGH' 261 | } 262 | } 263 | } 264 | } 265 | 266 | # fcm_payload = messaging.Message( 267 | # token=self.token, 268 | # data=data, 269 | # android=messaging.AndroidConfig( 270 | # ttl=datetime.timedelta(seconds=60), 271 | # priority='high' 272 | # ) 273 | # ) 274 | 275 | return http_payload 276 | -------------------------------------------------------------------------------- /config/opensips.cfg: -------------------------------------------------------------------------------- 1 | 2 | # OpenSIPS configuration 3 | 4 | This is a configuration example for handling push notifications for Linphone 5 | and Sylk Mobile applications. 6 | 7 | The token data generated by the device is sent as parameters to the Contact 8 | header. Later, when an INVITE comes in, if a token is found for the called 9 | party, a push notification is sent to each mobile device which will wake up 10 | and register while the call is in progress. These late registrations will 11 | cause OpenSIPS to fork the initial request to the newly registered contacts. 12 | 13 | 14 | ## SQL storage 15 | 16 | The token is saved into a MySQL table. 17 | 18 | ``` 19 | CREATE TABLE `push_tokens` ( 20 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 21 | `username` char(64) NOT NULL DEFAULT '', 22 | `domain` char(64) NOT NULL DEFAULT '', 23 | `platform` char(64) NOT NULL DEFAULT '', 24 | `app` char(255) NOT NULL DEFAULT '', 25 | `token` char(255) NOT NULL DEFAULT '', 26 | `sip_instance` char(255) NOT NULL DEFAULT '', 27 | `user_agent` char(255) NOT NULL DEFAULT '', 28 | `last_modified` datetime NOT NULL DEFAULT current_timestamp(), 29 | `silent` char(1) NOT NULL DEFAULT '0', 30 | PRIMARY KEY (`id`), 31 | UNIQUE KEY `token_idx` (`username`,`domain`,`sip_instance`,`app`) 32 | ); 33 | ``` 34 | 35 | 36 | ## Loaded modules 37 | 38 | ``` 39 | loadmodule "event_routing.so" 40 | ``` 41 | 42 | ## Handle REGISTER route 43 | 44 | ``` 45 | route[REGISTER] { 46 | ... 47 | 48 | # parse the Contact header paramaters or the Contact uri parameters of 49 | # the register and save the push token to the database 50 | 51 | $avp(sip_instance) = $(ct.fields(params){param.value,+sip.instance}); 52 | 53 | if (!$avp(sip_instance)) { 54 | $avp(sip_instance) = $(ct.fields(uri){uri.param,pn-device}); 55 | } 56 | 57 | if ($avp(sip_instance)) { 58 | # try to find if the device is mobile 59 | $avp(pn_type) = $(ct.fields(uri){uri.param,pn-type}); 60 | if (!$avp(pn_type)) { 61 | $avp(pn_type) = $(ct.fields(uri){uri.param,pn-type}); 62 | } 63 | $avp(pn_app) = $(ct.fields(uri){uri.param,app-id}); 64 | if (!$avp(pn_app)) { 65 | $avp(pn_app) = $(ct.fields(uri){uri.param,pn-app}); 66 | } 67 | $avp(pn_token) = $(ct.fields(uri){uri.param,pn-tok}); 68 | if (!$avp(pn_token)) { 69 | $avp(pn_token) = $(ct.fields(uri){uri.param,pn-tok}); 70 | } 71 | $avp(pn_silent) = $(ct.fields(uri){uri.param,pn-silent}); 72 | if (!$avp(pn_silent)) { 73 | $avp(pn_silent) = $(ct.fields(uri){uri.param,pn-silent}); 74 | } 75 | if (!$avp(pn_silent)) { 76 | $avp(pn_silent) = "0"; 77 | } 78 | } 79 | 80 | if (save("location")) { 81 | # save data required later for push notifications 82 | if ($avp(pn_type) and $avp(pn_token) and $avp(pn_app)) { 83 | $avp(query) = "SELECT token from push_tokens where username = '" + $(tU{s.escape.common}) + "' and domain = '" + $(td{s.escape.common}) + "' and app = '" + $(avp(pn_app){s.escape.common}) + "' and sip_instance = '" + $(avp(sip_instance){s.escape.common}) + "'"; 84 | xlog("L_DBG", "[CONFIG] REGISTER push SQL query: $avp(query)\n"); 85 | avp_db_query($avp(query), "$avp(old_pn_token)"); 86 | if (not $avp(old_pn_token)) { 87 | $avp(query) = "INSERT into push_tokens (username, domain, platform, app, token, sip_instance, user_agent, silent) values ('" + $(tU{s.escape.common}) + "', '" + $(td{s.escape.common}) + "', '" + $(avp(pn_type){s.escape.common}) + "', '" + $(avp(pn_app){s.escape.common}) + "', '" + $(avp(pn_token){s.escape.common}) + "', '" + $(avp(sip_instance){s.escape.common}) + "', '" + $(hdr(User-Agent){s.escape.common}) + "', '" + $(avp(pn_silent){s.escape.common}) + "')"; 88 | xlog("L_DBG", "[CONFIG] REGISTER push SQL query: $avp(query)\n"); 89 | avp_db_query($avp(query)); 90 | } else { 91 | $avp(query) = "UPDATE push_tokens set silent = '" + $(avp(pn_silent){s.escape.common}) + "', last_modified = NOW(), token = '" + $(avp(pn_token){s.escape.common}) + "' where sip_instance = '" + $(avp(sip_instance){s.escape.common}) + "' and app = '" + $(avp(pn_app){s.escape.common}) + "' and username = '" + $(tU{s.escape.common}) + " and domain = '" + $(td{s.escape.common}) + "'"; 92 | xlog("L_DBG", "[CONFIG] REGISTER push SQL query: $avp(query)\n"); 93 | avp_db_query($avp(query)); 94 | } 95 | } 96 | } 97 | } 98 | ``` 99 | 100 | 101 | ## Handle INVITE route 102 | 103 | ``` 104 | route[INVITE] { 105 | ... 106 | 107 | $avp(sip_application_type) = "audio"; 108 | 109 | if (has_body("application/sdp")) { 110 | if (search_body("m=audio")) { 111 | $avp(sip_application_type) = "audio"; 112 | } 113 | if (search_body("m=video")) { 114 | if (is_avp_set("$avp(sip_application_type)")) { 115 | pv_printf($avp(sip_application_type), "$avp(sip_application_type), video"); 116 | } else { 117 | $avp(sip_application_type) = "video"; 118 | } 119 | } 120 | if (search_body("(m=message).*?(MSRP)")) { 121 | if (search_body("a=file-selector")) { 122 | if (is_avp_set("$avp(sip_application_type)")) { 123 | pv_printf($avp(sip_application_type), "$avp(sip_application_type), file-transfer"); 124 | } else { 125 | $avp(sip_application_type) = "file-transfer"; 126 | } 127 | } else { 128 | if (is_avp_set("$avp(sip_application_type)")) { 129 | pv_printf($avp(sip_application_type), "$avp(sip_application_type), chat"); 130 | } else { 131 | $avp(sip_application_type) = "chat"; 132 | } 133 | } 134 | } 135 | } 136 | 137 | # load push notifications tokens saved during register and wake up the devices 138 | $avp(query) = "SELECT token, app, platform, sip_instance from push_tokens WHERE username='" + $(var(user){s.escape.common}) + "' AND domain='" + $(var(domain){s.escape.common}) + "'"; 139 | xlog("L_DBG", "[CONFIG] $avp(query)\n"); 140 | 141 | $var(i) = 0; 142 | store_dlg_value("late_forking","0"); 143 | 144 | if (avp_db_query($avp(query), "$avp(pn_token);$avp(pn_app);$avp(pn_platform);$avp(sip_instance)")) { 145 | $var(pn_event) = 'incoming_session'; 146 | $var(from) = $fU + "@" + $fd; 147 | $var(to) = $tU + "@" + $td; 148 | $var(ci) = $ci; 149 | 150 | for ($var(pn_token) in $(avp(pn_token)[*])) { 151 | $var(pn_app) = $(avp(pn_app)[$var(i)]); 152 | $var(pn_platform) = $(avp(pn_platform)[$var(i)]); 153 | $var(sip_instance) = $(avp(sip_instance)[$var(i)]); 154 | $var(i) = $var(i) + 1; 155 | 156 | $avp(late_forking) = 1; 157 | store_dlg_value("late_forking","1"); 158 | store_dlg_value("sip_application_type",$avp(sip_application_type)); 159 | $var(sip_application_type) = $avp(sip_application_type); 160 | 161 | # Launch method 162 | # launch is a fire and forget mechanism, the script does not block or wait, it just continues 163 | $var(push_command) = "/usr/local/bin/sylk-pushclient --url \"http://81.23.228.160:8400/push\" --platform=\"" + $var(pn_platform) + "\" --appid=\"" + $var(pn_app) + "\" --from_name=\"" + $(fn{s.escape.common}) + "\" --mediatype=\"" + $var(sip_application_type) + "\" --event=\"" + $var(pn_event) + "\" --token=\"" + $var(pn_token) + "\" --deviceid=\"" + $var(sip_instance) +"\" --callid=\"" + $ci + "\" --from=\"" + $var(from) + "\" --to=\"" + $var(to) + "\""; 164 | xlog("L_INFO", "[CONFIG] Push notification command: $var(push_command)\n"); 165 | launch(exec("$var(push_command)",, $var(rc)), "PN_RESULT"); 166 | } 167 | } 168 | 169 | if (not t_newtran()) { 170 | sl_reply_error(); 171 | exit; 172 | } 173 | 174 | if ($avp(late_forking)) { 175 | sl_send_reply(110, "Push sent"); 176 | t_wait_for_new_branches(); 177 | $avp(filter) = "aor="+$rU+"@"+$rd; 178 | notify_on_event("E_UL_CONTACT_INSERT",$avp(filter), "LATE_FORKING", 40); 179 | } 180 | 181 | if (lookup("location")) { 182 | xlog("L_INFO", "[CONFIG] $rU@$rd has online devices for $rm ($ci)\n"); 183 | } else { 184 | if ($avp(late_forking)) { 185 | xlog("L_INFO", "[CONFIG] $ru is not yet online ($ci)\n"); 186 | } else { 187 | sl_send_reply(480, "User not online"); 188 | } 189 | } 190 | } 191 | ``` 192 | 193 | ## Handle CANCEL route 194 | 195 | ``` 196 | route[CANCEL] { 197 | ... 198 | 199 | if ($dlg_val(late_forking) == "1") { 200 | $var(user) = $rU; 201 | $var(domain) = $rd; 202 | $var(sip_application_type) = $dlg_val(sip_application_type); 203 | 204 | $avp(query) = "SELECT token, app, platform, sip_instance from push_tokens WHERE username='" + $(var(user){s.escape.common}) + "' AND domain='" + $(var(domain){s.escape.common}) + "'"; 205 | xlog("L_DBG", "[CONFIG] $avp(query)\n"); 206 | 207 | if (avp_db_query($avp(query), "$avp(pn_token);$avp(pn_app);$avp(pn_platform);$avp(sip_instance)")) { 208 | $var(i) = 0; 209 | $var(pn_event) = 'cancel'; 210 | $var(from) = $fU + "@" + $fd; 211 | $var(to) = $tU + "@" + $td; 212 | $var(ci) = $ci; 213 | 214 | for ($var(pn_token) in $(avp(pn_token)[*])) { 215 | $var(pn_app) = $(avp(pn_app)[$var(i)]); 216 | $var(pn_platform) = $(avp(pn_platform)[$var(i)]); 217 | $var(sip_instance) = $(avp(sip_instance)[$var(i)]); 218 | 219 | $var(i) = $var(i) + 1; 220 | 221 | # Launch method 222 | # launch is a fire and forget mechanism, the script does not block or wait, it just continues 223 | $var(push_command) = "/etc/opensips/config/siteconfig/pusher.py --url \"http://81.23.228.160:8400/push\" --platform=\"" + $var(pn_platform) + "\" --appid=\"" + $var(pn_app) + "\" --from_name=\"" + $(fn{s.escape.common}) + "\" --mediatype=\"" + $var(sip_application_type) + "\" --event=\"" + $var(pn_event) + "\" --token=\"" + $var(pn_token) + "\" --deviceid=\"" + $var(sip_instance) +"\" --callid=\"" + $ci + "\" --from=\"" + $var(from) + "\" --to=\"" + $var(to) + "\""; 224 | xlog("L_INFO", "[CONFIG] Push notification command: $var(push_command)\n"); 225 | launch(exec("$var(push_command)",, $var(rc)), "PN_RESULT"); 226 | } 227 | } 228 | } 229 | } 230 | ``` 231 | 232 | ## Handle late forking route 233 | 234 | ``` 235 | route[LATE_FORKING] { 236 | # handle incoming calls for mobile devices woken up by push notifications 237 | 238 | xlog("L_INFO", "$avp(aor) registered contact $avp(uri) while receiving an incoming call"); 239 | # take the contact described by the E_UL_CONTACT_INSERT 240 | # event and inject it as a new branch into the original 241 | # transaction 242 | t_inject_branches("event"); 243 | } 244 | ``` 245 | 246 | ## Handle push result route 247 | 248 | ``` 249 | route[PN_RESULT] { 250 | if ($var(retcode) == "1") { 251 | xlog("L_INFO", "[CONFIG] $var(pn_event) push notification for $var(to) on device $var(sip_instance) ($var(ci)) suceeded\n"); 252 | } else { 253 | xlog("L_INFO", "[CONFIG] $var(pn_event) push notification for $var(to) on device $var(sip_instance) ($var(ci)) failed\n"); 254 | } 255 | } 256 | ``` 257 | -------------------------------------------------------------------------------- /pushserver/api/routes/v2/push.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status 4 | from fastapi.responses import JSONResponse 5 | 6 | from fastapi.encoders import jsonable_encoder 7 | from pydantic import ValidationError 8 | from typing import Optional 9 | 10 | from pushserver.models.requests import WakeUpRequest, PushRequest 11 | from pushserver.resources import settings 12 | from pushserver.resources.storage import TokenStorage 13 | from pushserver.resources.storage.errors import StorageError 14 | from pushserver.resources.notification import handle_request 15 | from pushserver.resources.utils import (check_host, 16 | log_event, log_incoming_request, 17 | log_push_request, 18 | fix_platform_name) 19 | 20 | router = APIRouter() 21 | 22 | 23 | async def task_push(account: str, 24 | push_request: PushRequest, 25 | request_id: str, 26 | host: str, 27 | device: Optional[str] = None): 28 | 29 | code, description, data = '', '', [] 30 | storage = TokenStorage() 31 | try: 32 | storage_data = storage[account] 33 | except StorageError: 34 | log_push_request(task='log_failure', 35 | host=host, loggers=settings.params.loggers, 36 | request_id=request_id, body=push_request.__dict__, 37 | error_msg=f'500: {{\"detail\": \"{error.detail}\"}}') 38 | return 39 | expired_devices = [] 40 | 41 | if not storage_data: 42 | # Push request was not sent: user not found 43 | storage.remove(account) 44 | return 45 | 46 | for device_key, push_parameters in storage_data.items(): 47 | if device is not None and device != push_parameters['device_id']: 48 | continue 49 | 50 | push_parameters.update(push_request.__dict__) 51 | 52 | push_parameters['platform'] = fix_platform_name(push_parameters['platform']) 53 | 54 | reversed_push_parameters = {} 55 | for item in push_parameters.keys(): 56 | value = push_parameters[item] 57 | if item in ('sip_to', 'sip_from'): 58 | item = item.split('_')[1] 59 | else: 60 | item = item.replace('_', '-') 61 | reversed_push_parameters[item] = value 62 | 63 | # Use background_token for cancel 64 | if push_parameters['event'] == 'cancel' and push_parameters['background_token'] is not None: 65 | reversed_push_parameters['token'] = push_parameters['background_token'] 66 | 67 | # if push_parameters['silent']: 68 | # continue 69 | 70 | try: 71 | wp = WakeUpRequest(**reversed_push_parameters) 72 | except ValidationError as e: 73 | error_msg = e.errors()[0]['msg'] 74 | log_push_request(task='log_failure', host=host, 75 | loggers=settings.params.loggers, 76 | request_id=request_id, body=push_request.__dict__, 77 | error_msg=error_msg) 78 | return 79 | 80 | log_incoming_request(task='log_success', 81 | host=host, loggers=settings.params.loggers, 82 | request_id=request_id, body=wp.__dict__) 83 | results = handle_request(wp, request_id=request_id) 84 | 85 | code = results.get('code') 86 | if code == 410: 87 | expired_devices.append((push_parameters['app_id'], push_parameters['device_id'])) 88 | code = 200 89 | description = 'push notification responses' 90 | data.append(results) 91 | 92 | for device in expired_devices: 93 | msg = f'Removing {device[1]} from {account}' 94 | log_event(loggers=settings.params.loggers, 95 | msg=msg, level='deb') 96 | storage.remove(account, *device) 97 | 98 | if code == '': 99 | description, data = 'Push request was not sent: device not found', {"device_id": push_parameters['device_id']} 100 | log_event(loggers=settings.params.loggers, 101 | msg=f'{description} {data}', level='warn') 102 | else: 103 | log_event(loggers=settings.params.loggers, 104 | msg=f'{description} {data}', level='deb') 105 | 106 | 107 | @router.post('/{account}/push', response_model=PushRequest) 108 | @router.post('/{account}/push/{device}', response_model=PushRequest) 109 | async def push_requests(account: str, 110 | request: Request, 111 | push_request: PushRequest, 112 | background_tasks: BackgroundTasks, 113 | device: Optional[str] = None): 114 | 115 | host, port = request.client.host, request.client.port 116 | 117 | code, description, data = '', '', [] 118 | 119 | if check_host(host, settings.params.allowed_pool): 120 | request_id = f"{push_request.event}-{account}-{push_request.call_id}" 121 | 122 | if not settings.params.return_async: 123 | background_tasks.add_task(log_push_request, task='log_request', 124 | host=host, loggers=settings.params.loggers, 125 | request_id=request_id, body=push_request.__dict__) 126 | background_tasks.add_task(log_incoming_request, task='log_success', 127 | host=host, loggers=settings.params.loggers, 128 | request_id=request_id, body=push_request.__dict__) 129 | background_tasks.add_task(task_push, 130 | account=account, 131 | push_request=push_request, 132 | request_id=request_id, 133 | host=host, 134 | device=device) 135 | status_code, code = status.HTTP_202_ACCEPTED, 202 136 | description, data = 'accepted for delivery', {} 137 | 138 | try: 139 | return JSONResponse(status_code=status_code, 140 | content={'code': code, 141 | 'description': description, 142 | 'data': data}) 143 | except json.decoder.JSONDecodeError: 144 | return JSONResponse(status_code=status_code, 145 | content={'code': code, 146 | 'description': description, 147 | 'data': {}}) 148 | 149 | else: 150 | storage = TokenStorage() 151 | try: 152 | storage_data = storage[account] 153 | except StorageError: 154 | error = HTTPException(status_code=500, detail="Internal error: storage") 155 | log_push_request(task='log_failure', 156 | host=host, loggers=settings.params.loggers, 157 | request_id=request_id, body=push_request.__dict__, 158 | error_msg=f'500: {{\"detail\": \"{error.detail}\"}}') 159 | raise error 160 | expired_devices = [] 161 | 162 | log_push_request(task='log_request', 163 | host=host, loggers=settings.params.loggers, 164 | request_id=request_id, body=push_request.__dict__) 165 | if not storage_data: 166 | description, data = 'Push request was not sent: user not found', {"account": account} 167 | storage.remove(account) 168 | return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, 169 | content={'code': 404, 170 | 'description': description, 171 | 'data': data}) 172 | 173 | for device_key, push_parameters in storage_data.items(): 174 | if device is not None and device != push_parameters['device_id']: 175 | continue 176 | 177 | push_parameters.update(push_request.__dict__) 178 | 179 | push_parameters['platform'] = fix_platform_name(push_parameters['platform']) 180 | 181 | reversed_push_parameters = {} 182 | for item in push_parameters.keys(): 183 | value = push_parameters[item] 184 | if item in ('sip_to', 'sip_from'): 185 | item = item.split('_')[1] 186 | else: 187 | item = item.replace('_', '-') 188 | reversed_push_parameters[item] = value 189 | 190 | # Use background_token for cancel and message 191 | if push_parameters['event'] in ('cancel', 'message') and push_parameters['background_token'] is not None: 192 | reversed_push_parameters['token'] = push_parameters['background_token'] 193 | 194 | # if push_parameters['silent']: 195 | # continue 196 | 197 | try: 198 | wp = WakeUpRequest(**reversed_push_parameters) 199 | except ValidationError as e: 200 | error_msg = e.errors()[0]['msg'] 201 | log_push_request(task='log_failure', host=host, 202 | loggers=settings.params.loggers, 203 | request_id=request_id, body=push_request.__dict__, 204 | error_msg=error_msg) 205 | content = jsonable_encoder({'code': 400, 206 | 'description': error_msg, 207 | 'data': ''}) 208 | return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, 209 | content=content) 210 | 211 | log_incoming_request(task='log_success', 212 | host=host, loggers=settings.params.loggers, 213 | request_id=request_id, body=wp.__dict__) 214 | results = handle_request(wp, request_id=request_id) 215 | 216 | code = results.get('code') 217 | if code == 410: 218 | expired_devices.append((push_parameters['app_id'], push_parameters['device_id'])) 219 | code = 200 220 | description = 'push notification responses' 221 | data.append(results) 222 | 223 | for expired_device in expired_devices: 224 | msg = f'Removing {expired_device[1]} from {account}' 225 | log_event(loggers=settings.params.loggers, 226 | msg=msg, level='info') 227 | storage.remove(account, *expired_device) 228 | 229 | if code == '': 230 | description, data = 'Push request was not sent: device not found', {"device_id": push_parameters['device_id']} 231 | content = {'code': 404, 232 | 'description': description, 233 | 'data': data} 234 | log_push_request(task='log_failure', 235 | host=host, loggers=settings.params.loggers, 236 | request_id=request_id, body=push_request.__dict__, 237 | error_msg=f'{content}') 238 | return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, 239 | content=content) 240 | 241 | else: 242 | msg = f'incoming request from {host} is denied' 243 | log_event(loggers=settings.params.loggers, 244 | msg=msg, level='deb') 245 | code = 403 246 | description = 'access denied by access list' 247 | data = {} 248 | 249 | log_event(loggers=settings.params.loggers, 250 | sg=msg, level='deb') 251 | 252 | return JSONResponse(status_code=code, content={'code': code, 253 | 'description': description, 254 | 'data': data}) 255 | -------------------------------------------------------------------------------- /pushserver/resources/settings.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | import os 4 | from ipaddress import ip_network 5 | from pushserver.pns.register import get_pns_from_config 6 | from application import log 7 | 8 | 9 | class ConfigParams(object): 10 | """ 11 | Settings params to share across modules. 12 | 13 | :param dir: `dict` with 'path' to config dir an 'error' if exists 14 | :param file: `dict` with 'path' to config file 15 | :param server: `dict` with host, port and tls_cert from config file 16 | :param apps: `dict` with path, credentials and extra_dir from config file 17 | :param loggers: `dict` global logging instances to write messages (params.loggers) 18 | :param allowed_pool: `list` of allowed hosts for requests 19 | 20 | if there is any error with config dir or config file, 21 | others params will be setted to None. 22 | """ 23 | 24 | def __init__(self, config_dir, debug, ip, port): 25 | 26 | self.default_host, self.default_port = '127.0.0.1', '8400' 27 | 28 | self.config_dir = config_dir 29 | self.debug = debug 30 | self.ip, self.port = ip, port 31 | 32 | self.cfg_file = f'general.ini' 33 | self.dir = self.set_dir() 34 | self.file = self.set_file() 35 | self.loggers = self.set_loggers() 36 | self.apps = self.set_apps() 37 | self.register = self.set_register() 38 | self.allowed_pool = self.set_allowed_pool() 39 | self.return_async = self.set_return_async() 40 | 41 | def set_dir(self): 42 | """ 43 | if config directory was not specified from command line 44 | look for general.ini in /etc/sylk-pushserver 45 | if general.ini is not there, server will start with default settings 46 | """ 47 | dir, error = {}, '' 48 | 49 | config_dir = self.config_dir 50 | 51 | msg = f"Reading configuration from {config_dir}" 52 | log.info(msg) 53 | 54 | if not os.path.exists(f'{self.config_dir}/{self.cfg_file}'): 55 | config_dir = '' 56 | error = f'No {self.cfg_file} found in {self.config_dir}, ' \ 57 | f'server will run with default settings.' 58 | 59 | dir['path'], dir['error'] = config_dir, error 60 | return dir 61 | 62 | def set_file(self): 63 | 64 | file, path, error = {}, '', '' 65 | if not self.dir.get('error'): 66 | path = f"{self.dir['path']}/{self.cfg_file}" 67 | error = '' 68 | elif 'default' in self.dir.get('error'): 69 | path = '' 70 | error = self.dir.get('error') 71 | 72 | file['path'], file['error'] = path, error 73 | return file 74 | 75 | @property 76 | def server(self): 77 | server = {} 78 | 79 | if not self.file.get('error') or 'default' in self.file.get('error'): 80 | config = configparser.ConfigParser() 81 | config.read(self.file['path']) 82 | try: 83 | server_settings = config['server'] 84 | except KeyError: 85 | server_settings = {} 86 | if self.ip: 87 | server['host'] = self.ip 88 | else: 89 | server['host'] = server_settings.get('host') or self.default_host 90 | if self.port: 91 | server['port'] = self.port 92 | else: 93 | server['port'] = server_settings.get('port') or self.default_port 94 | 95 | server['tls_cert'] = server_settings.get('tls_certificate') or '' 96 | return server 97 | 98 | def set_apps(self): 99 | 100 | apps = {} 101 | apps_path = f'{self.config_dir}/applications.ini' 102 | apps_cred = f'{self.config_dir}/credentials' 103 | apps_extra_dir = f'{self.config_dir}/applications' 104 | pns_extra_dir = f'{self.config_dir}/pns' 105 | 106 | if self.file['path']: 107 | logging.info(f"Reading: {self.file['path']}") 108 | config = configparser.ConfigParser() 109 | config.read(self.file['path']) 110 | 111 | config_apps_path = f"{config['applications'].get('config_file')}" 112 | config_apps_cred = f"{config['applications'].get('credentials_folder')}" 113 | config_apps_extra_dir = f"{config['applications'].get('extra_applications_dir')}" 114 | config_pns_extra_dir = f"{config['applications'].get('extra_pns_dir')}" 115 | paths_list = [config_apps_path, config_apps_cred, config_apps_extra_dir, config_pns_extra_dir] 116 | 117 | for i, path in enumerate(paths_list): 118 | if not path.startswith('/'): 119 | paths_list[i] = f'{self.config_dir}/{path}' 120 | 121 | config_apps_path = paths_list[0] 122 | config_apps_cred = paths_list[1] 123 | config_apps_extra_dir = paths_list[2] 124 | config_pns_extra_dir = paths_list[3] 125 | 126 | apps_path_exists = os.path.exists(config_apps_path) 127 | cred_path_exists = os.path.exists(config_apps_cred) 128 | extra_apps_dir_exists = os.path.exists(config_apps_extra_dir) 129 | extra_pns_dir_exists = os.path.exists(config_pns_extra_dir) 130 | 131 | if apps_path_exists: 132 | apps_path = config_apps_path 133 | if cred_path_exists: 134 | apps_cred = config_apps_cred 135 | if extra_apps_dir_exists: 136 | apps_extra_dir = config_apps_extra_dir 137 | if extra_pns_dir_exists: 138 | pns_extra_dir = config_pns_extra_dir 139 | else: 140 | logging.info(self.dir['error']) 141 | 142 | if not os.path.exists(apps_path): 143 | self.dir['error'] = f'Required config file not found: {apps_path}' 144 | apps_path, apps_cred, apps_extra_dir = '', '', '' 145 | else: 146 | logging.info(f'Reading: {apps_path}') 147 | config = configparser.ConfigParser() 148 | config.read(apps_path) 149 | if config.sections(): 150 | for id in config.sections(): 151 | try: 152 | config[id]['app_id'] 153 | config[id]['app_type'] 154 | config[id]['app_platform'].lower() 155 | except KeyError: 156 | self.dir['error'] = f'Can not start: ' \ 157 | f'{apps_path} config file has not ' \ 158 | f'valid application settings' 159 | apps_path, apps_cred, apps_extra_dir = '', '', '' 160 | 161 | apps['path'] = apps_path 162 | apps['credentials'] = apps_cred 163 | apps['apps_extra_dir'] = apps_extra_dir 164 | apps['pns_extra_dir'] = pns_extra_dir 165 | return apps 166 | 167 | def set_loggers(self): 168 | 169 | debug = self.debug if self.debug else False 170 | loggers = {} 171 | config = configparser.ConfigParser() 172 | default_path = '/var/log/sylk-pushserver/push.log' 173 | log_path = '' 174 | 175 | if not self.file['error']: 176 | config.read(self.file['path']) 177 | 178 | try: 179 | log_to_file = f"{config['server']['log_to_file']}" 180 | log_to_file = True if log_to_file.lower() == 'true' else False 181 | except KeyError: 182 | pass 183 | else: 184 | if log_to_file: 185 | try: 186 | log_path = f"{config['server']['log_file']}" 187 | except KeyError: 188 | log_path = default_path 189 | 190 | try: 191 | str_debug = config['server']['debug'].lower() 192 | except KeyError: 193 | str_debug = False 194 | debug = True if str_debug == 'true' else False 195 | debug = debug or self.debug 196 | 197 | formatter = logging.Formatter('%(asctime)s [%(levelname)-8s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') 198 | logger_journal = logging.getLogger() 199 | 200 | loggers['to_journal'] = logger_journal 201 | 202 | if log_path: 203 | try: 204 | hdlr = logging.FileHandler(log_path) 205 | hdlr.setFormatter(formatter) 206 | hdlr.setLevel(logging.DEBUG) 207 | logger_journal.addHandler(hdlr) 208 | except PermissionError: 209 | logger_journal.warning(f'Permission denied for log file: {log_path}, ' \ 210 | f'logging will only be in the journal or foreground') 211 | 212 | debug = debug or self.debug 213 | loggers['debug'] = debug 214 | if debug: 215 | logger_journal.setLevel(logging.DEBUG) 216 | 217 | debug_hpack = False 218 | try: 219 | debug_hpack = True if f"{config['server']['debug_hpack']}".lower() == 'true' else False 220 | except KeyError: 221 | pass 222 | 223 | if not debug_hpack: 224 | logging.getLogger("hpack").setLevel(logging.INFO) 225 | 226 | return loggers 227 | 228 | def set_register(self): 229 | if not self.dir['error'] or 'default' in self.dir['error']: 230 | apps_path, apps_cred = self.apps['path'], self.apps['credentials'] 231 | apps_extra_dir = self.apps['apps_extra_dir'] 232 | pns_extra_dir = self.apps['pns_extra_dir'] 233 | return get_pns_from_config(config_path=apps_path, 234 | credentials=apps_cred, 235 | apps_extra_dir=apps_extra_dir, 236 | pns_extra_dir=pns_extra_dir, 237 | loggers=self.loggers) 238 | 239 | @property 240 | def pns_register(self): 241 | return self.register['pns_register'] 242 | 243 | @property 244 | def invalid_apps(self): 245 | return self.register['invalid_apps'] 246 | 247 | @property 248 | def pnses(self): 249 | return self.register['pnses'] 250 | 251 | def set_allowed_pool(self): 252 | if self.dir['error']: 253 | return None 254 | 255 | if not self.file['path']: 256 | return None 257 | 258 | allowed_pool = [] 259 | allowed_hosts_str = '' 260 | config = configparser.ConfigParser() 261 | config.read(self.file['path']) 262 | try: 263 | allowed_hosts_str = config['server']['allowed_hosts'] 264 | allowed_hosts = allowed_hosts_str.split(', ') 265 | except KeyError: 266 | return allowed_pool 267 | except SyntaxError: 268 | error = f'allowed_hosts = {allowed_hosts_str} - bad syntax' 269 | self.dir['error'] = error 270 | return allowed_pool 271 | 272 | if type(allowed_hosts) not in (list, tuple): 273 | error = f'allowed_hosts = {allowed_hosts} - bad syntax' 274 | self.dir['error'] = error 275 | return allowed_pool 276 | 277 | config.read(self.file['path']) 278 | for addr in allowed_hosts: 279 | try: 280 | net = f'{addr}/32' if '/' not in addr else addr 281 | allowed_pool.append(ip_network(net)) 282 | except ValueError as e: 283 | error = f'wrong acl settings: {e}' 284 | self.dir['error'] = error 285 | return [] 286 | 287 | return set(allowed_pool) 288 | 289 | def set_return_async(self): 290 | return_async = True 291 | config = configparser.ConfigParser() 292 | 293 | if not self.file['error']: 294 | config.read(self.file['path']) 295 | try: 296 | return_async = config['server']['return_async'] 297 | return_async = True if return_async.lower() == 'true' else False 298 | 299 | except KeyError: 300 | return_async = True 301 | 302 | return return_async 303 | 304 | 305 | def init(config_dir, debug, ip, port): 306 | global params 307 | params = ConfigParams(config_dir, debug, ip, port) 308 | return params 309 | 310 | 311 | def update_params(config_dir, debug, ip, port): 312 | global params 313 | try: 314 | params = ConfigParams(config_dir, debug, ip, port) 315 | except Exception as ex: 316 | print(f'Settings can not be updated, reason: {ex}') 317 | return params 318 | -------------------------------------------------------------------------------- /pushserver/pns/firebase.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | from datetime import datetime 5 | 6 | import oauth2client 7 | import requests 8 | 9 | from pushserver.models.requests import WakeUpRequest 10 | 11 | from requests.adapters import HTTPAdapter 12 | from urllib3 import Retry 13 | 14 | from pushserver.pns.base import PNS, PushRequest, PlatformRegister 15 | from pushserver.resources.utils import log_event, fix_non_serializable_types 16 | 17 | #import firebase_admin 18 | #from firebase_admin import messaging 19 | #default_app = firebase_admin.initialize_app() 20 | 21 | 22 | class FirebasePNS(PNS): 23 | """ 24 | A Firebase Push Notification service 25 | """ 26 | 27 | def __init__(self, app_id: str, app_name: str, url_push: str, 28 | voip: bool, auth_key: str = None, auth_file: str = None): 29 | """ 30 | :param app_id `str`: Application ID. 31 | :param url_push `str`: URI to push a notification. 32 | :param voip `bool`: required for apple, `True` for voip push notification type. 33 | :param auth_key `str`: A Firebase credential for push notifications. 34 | :param auth_file `str`: A Firebase credential for push notifications. 35 | """ 36 | self.app_id = app_id 37 | self.app_name = app_name 38 | self.url_push = url_push 39 | self.voip = voip 40 | self.auth_key = auth_key 41 | self.auth_file = auth_file 42 | self.error = '' 43 | 44 | 45 | class FirebaseRegister(PlatformRegister): 46 | def __init__(self, app_id: str, app_name: str, voip: bool, 47 | config_dict: dict, credentials_path: str, loggers: dict): 48 | 49 | self.app_id = app_id 50 | self.app_name = app_name 51 | self.voip = voip 52 | 53 | self.credentials_path = credentials_path 54 | self.config_dict = config_dict 55 | self.loggers = loggers 56 | 57 | self.error = '' 58 | self.auth_key, self.auth_file = self.set_auths() 59 | 60 | @property 61 | def url_push(self): 62 | try: 63 | return self.config_dict['firebase_push_url'] 64 | except KeyError: 65 | self.error = 'firebase_push_url not found in applications.ini' 66 | return None 67 | 68 | def set_auths(self): 69 | auth_key = None 70 | auth_file = None 71 | try: 72 | auth_key = self.config_dict['firebase_authorization_key'] 73 | except KeyError: 74 | try: 75 | auth_file = self.config_dict['firebase_authorization_file'] 76 | if self.credentials_path: 77 | auth_file = f"{self.credentials_path}/" \ 78 | f"{auth_file}" 79 | else: 80 | pass 81 | if not os.path.exists(auth_file): 82 | self.error = f'{auth_file} - no such file' 83 | except KeyError: 84 | self.error = 'not firebase_authorization_key or ' \ 85 | 'firebase_authorization_file found in applications.ini' 86 | 87 | return auth_key, auth_file 88 | 89 | @property 90 | def pns(self) -> FirebasePNS: 91 | pns = None 92 | if self.auth_key: 93 | auth_file = '' 94 | pns = FirebasePNS(app_id=self.app_id, 95 | app_name=self.app_name, 96 | url_push=self.url_push, 97 | voip=self.voip, 98 | auth_key=self.auth_key, 99 | auth_file=auth_file) 100 | elif self.auth_file: 101 | pns = FirebasePNS(app_id=self.app_id, 102 | app_name=self.app_name, 103 | url_push=self.url_push, 104 | voip=self.voip, 105 | auth_file=self.auth_file) 106 | 107 | self.error = pns.error if pns.error else '' 108 | return pns 109 | 110 | @property 111 | def register_entries(self): 112 | if self.error: 113 | return {} 114 | 115 | return {'pns': self.pns, 116 | 'auth_key': self.auth_key, 117 | 'auth_file': self.auth_file} 118 | 119 | 120 | class FirebasePushRequest(PushRequest): 121 | """ 122 | Firebase push notification request 123 | """ 124 | 125 | def __init__(self, error: str, app_name: str, app_id: str, 126 | request_id: str, headers: str, payload: dict, 127 | loggers: dict, log_remote: dict, 128 | wp_request: WakeUpRequest, register: dict): 129 | 130 | """ 131 | :param error: `str` 132 | :param app_name: `str` 'linphone' or 'payload' 133 | :param app_id: `str` bundle id 134 | :param headers: `FirebaseHeaders` Firebase push notification headers 135 | :param payload: `FirebasePayload`Firebase push notification payload 136 | :param wp_request: `WakeUpRequest` 137 | :param loggers: `dict` global logging instances to write messages (params.loggers) 138 | """ 139 | self.error = error 140 | self.app_name = app_name 141 | self.app_id = app_id 142 | self.platform = 'firebase' 143 | self.request_id = request_id 144 | self.headers = headers 145 | self.payload = payload 146 | self.token = wp_request.token 147 | self.wp_request = wp_request 148 | self.loggers = loggers 149 | self.log_remote = log_remote 150 | 151 | self.pns = register['pns'] 152 | 153 | self.path = self.pns.url_push 154 | self.results = self.send_http_notification() 155 | # self.results = self.send_fcm_notification() 156 | 157 | def requests_retry_session(self, counter=0): 158 | """ 159 | Define parameters to retry a push notification 160 | according to media_type. 161 | :param counter: `int` (optional) if retries was necessary 162 | because of connection fails 163 | 164 | Following rfc3261 specification, an exponential backoff factor is used. 165 | More specifically: 166 | backoff = 0.5 167 | T1 = 500ms 168 | max_retries_call = 7 169 | time_to_live_call = 64 seconds 170 | max_retries_sms = 11 171 | time_to_live_sms ~ 2 hours 172 | """ 173 | 174 | retries = self.retries_params[self.media_type] - counter 175 | backoff_factor = self.retries_params['bo_factor'] * 0.5 * 2 ** counter 176 | 177 | status_forcelist = tuple([status for status in range(500, 600)]) 178 | session = None 179 | 180 | session = session or requests.Session() 181 | retry = Retry( 182 | total=retries, 183 | read=retries, 184 | connect=retries, 185 | backoff_factor=backoff_factor, 186 | status_forcelist=status_forcelist) 187 | adapter = HTTPAdapter(max_retries=retry) 188 | session.mount('http://', adapter) 189 | session.mount('https://', adapter) 190 | return session 191 | 192 | def send_http_notification(self) -> dict: 193 | """ 194 | Send a Firebase push notification over HTTP 195 | """ 196 | 197 | if self.error: 198 | self.log_error() 199 | return {'code': 500, 'body': {}, 'reason': 'Internal server error'} 200 | 201 | n_retries, backoff_factor = self.retries_params(self.wp_request.media_type) 202 | 203 | counter = 0 204 | error = False 205 | code = 500 206 | reason = "" 207 | body = None 208 | response = None 209 | 210 | while counter <= n_retries: 211 | self.log_request(path=self.pns.url_push) 212 | try: 213 | response = requests.post(self.pns.url_push, 214 | self.payload, 215 | headers=self.headers) 216 | break 217 | except requests.exceptions.RequestException as e: 218 | error = True 219 | reason = f'connection failed: {e}' 220 | counter += 1 221 | timer = backoff_factor * (2 ** (counter - 1)) 222 | time.sleep(timer) 223 | 224 | if counter == n_retries: 225 | reason = "maximum retries reached" 226 | 227 | elif error: 228 | try: 229 | response = self.requests_retry_session(counter). \ 230 | post(self.pns.url_push, 231 | self.payload, 232 | headers=self.headers) 233 | except Exception as x: 234 | level = 'error' 235 | msg = f"outgoing {self.platform.title()} response for " \ 236 | f"{self.request_id}, push failed: " \ 237 | f"an error occurred in {x.__class__.__name__}" 238 | log_event(loggers=self.loggers, msg=msg, level=level) 239 | try: 240 | body = response.__dict__ 241 | except (TypeError, ValueError): 242 | code = 500 243 | reason = 'cannot parse response body' 244 | body = {} 245 | else: 246 | reason = body.get('reason') 247 | code = response.status_code 248 | 249 | for k in ('raw', 'request', 'connection', 'cookies', 'elapsed'): 250 | try: 251 | del body[k] 252 | except KeyError: 253 | pass 254 | except TypeError: 255 | break 256 | 257 | body = json.dumps(fix_non_serializable_types(body)) 258 | 259 | if isinstance(body, str): 260 | body = json.loads(body) 261 | 262 | if code == 200: 263 | description = 'OK' 264 | try: 265 | failure = body['_content']['failure'] 266 | except KeyError: 267 | pass 268 | else: 269 | if failure == 1: 270 | description = body['_content']['results'][0]['error'] 271 | code = 410 272 | 273 | else: 274 | try: 275 | reason = body['reason'] 276 | except KeyError: 277 | reason = None 278 | 279 | try: 280 | details = body['_content']['error']['message'] 281 | except KeyError: 282 | details = None 283 | 284 | try: 285 | internal_code = body['_content']['error']['code'] 286 | except KeyError: 287 | internal_code = None 288 | 289 | if internal_code == 400 and 'not a valid FCM registration token' in details: 290 | code = 410 291 | elif internal_code == 404: 292 | code = 410 293 | 294 | if reason and details: 295 | description = "%s %s" % (reason, details) 296 | elif reason: 297 | description = reason 298 | elif details: 299 | error_description = details 300 | else: 301 | description = 'unknown failure reason' 302 | 303 | keys = list(body.keys()) 304 | for key in keys: 305 | if not body[key]: 306 | del body[key] 307 | 308 | results = {'body': body, 309 | 'code': code, 310 | 'reason': description, 311 | 'url': self.pns.url_push, 312 | 'platform': 'firebase', 313 | 'call_id': self.wp_request.call_id, 314 | 'token': self.token 315 | } 316 | 317 | self.results = results 318 | self.log_results() 319 | return results 320 | 321 | def send_fcm_notification(self) -> dict: 322 | """ 323 | Send a native Firebase push notification 324 | """ 325 | 326 | if self.error: 327 | self.log_error() 328 | return {'code': 500, 'body': {}, 'reason': 'Internal server error'} 329 | 330 | n_retries, backoff_factor = self.retries_params(self.wp_request.media_type) 331 | 332 | counter = 0 333 | error = False 334 | code = 200 335 | response = None 336 | body = None 337 | reason = None 338 | 339 | while counter <= n_retries: 340 | self.log_request(path=self.pns.url_push) 341 | 342 | try: 343 | response = messaging.send(self.payload['fcm']) 344 | break 345 | except Exception as e: 346 | error = True 347 | response = f'connection failed: {e}' 348 | counter += 1 349 | timer = backoff_factor * (2 ** (counter - 1)) 350 | conde = 500 351 | time.sleep(timer) 352 | 353 | if counter == n_retries: 354 | reason = "maximum retries reached" 355 | 356 | elif error: 357 | try: 358 | response = self.requests_retry_session(counter). \ 359 | post(self.pns.url_push, 360 | self.payload, 361 | headers=self.headers) 362 | except Exception as x: 363 | level = 'error' 364 | msg = f"outgoing {self.platform.title()} response for " \ 365 | f"{self.request_id}, push failed: " \ 366 | f"an error occurred in {x.__class__.__name__}" 367 | log_event(loggers=self.loggers, msg=msg, level=level) 368 | 369 | body = {'response': response} 370 | results = {'body': body, 371 | 'code': code, 372 | 'reason': reason, 373 | 'url': self.pns.url_push, 374 | 'platform': 'firebase', 375 | 'call_id': self.wp_request.call_id, 376 | 'token': self.token 377 | } 378 | 379 | self.results = results 380 | self.log_results() 381 | return results 382 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Sylk Pushserver 3 | 4 | [Home page](http://sylkserver.com) 5 | 6 | Copyright (C) 2023 AG Projects 7 | 8 | Sylk Pushserver was designed to act as a central dispatcher for mobile push 9 | notifications inside RTC provider infrastructures. Both the provider and 10 | the mobile application customer, in the case of a shared infrastructure, can 11 | easily audit problems related to the processing of push notifications. 12 | 13 | Authors: 14 | 15 | * Bibiana Rivadeneira 16 | * Tijmen de Mes 17 | 18 | 19 | ## License 20 | 21 | Sylk Pushserver is licensed under GNU General Public License version 3. 22 | [Copy of the license](http://www.fsf.org/licensing/licenses/gpl-3.0.html) 23 | 24 | 25 | ## Deployment scenarios 26 | 27 | Sylk Pushserver can be deployed together with WebRTC server applications or 28 | VoIP servers like SIP Proxies and PBXs. Its main purpose is to act as a 29 | central entity inside RTC provider infrastructures. Without such a 30 | component, the same functionality must be built inside multiple servers and 31 | as the number of mobile applications increases, the need for such central 32 | component becomes obvious. 33 | 34 | ### Integration examples 35 | 36 | * OpenSIPS: **config/opensips.cfg** 37 | * SylkServer: built-in support 38 | 39 | 40 | ## Design 41 | 42 | Sylk Pushserver can handle an arbitrary number of different combinations of 43 | push notification service and mobile applications. It and can be extended 44 | by using Python programming language to support new push notification 45 | services and applications. Sample applications are provided to handle Sylk 46 | and Linphone mobile applications for Apple and Firebase push notification 47 | services. 48 | 49 | For each configured Apple application, the server maintains a persistent 50 | connection by using HTTP/2 over TLS 1.2 and reuses that connection for 51 | sending notifications related to the application. Latest voip functionality 52 | for iOS 13 or later is also suported. 53 | 54 | Each outgoing connection can use its own set of credentials, X.509 55 | certificates and urls. The connection failures are properly handled and 56 | incoming requests remained queued for later by using a timer dependent on 57 | the payload type. 58 | 59 | 60 | ### Logging 61 | 62 | All incoming and outgoing requests, including HTTP headers and bodies, can 63 | be logged for troubleshooting purposes in the system journal and in a 64 | separate log file. These logs can easily be correlated with the logs from 65 | the server that generated the request by using the call-id key. 66 | 67 | Remote HTTP logging of the results is possible so that one or more 68 | third-parties can receive information about the individual push requests and 69 | responses for each application. 70 | 71 | 72 | ## API 73 | 74 | Sylk Pushserver expects a json over HTTP POST requests and translates it 75 | into a correspondent outgoing push notifications request to Apple Push 76 | Notifications or Firebase FCM servers. 77 | 78 | Json object structure: 79 | 80 | ```{ 81 | 'app-id': 'com.agprojects.sylk-ios', 82 | 'platform': 'apple', 83 | 'token': '6688-71a883fe', 84 | 'device-id': 'accc8375125582aae062353', 85 | 'call-id': '4dbe8-7a53-42bd-95f3-9a7d43938', 86 | 'from': 'alice@example.com', 87 | 'from_display_name': 'Alice', 88 | 'to': 'bob@biloxi.com', 89 | 'media-type':'audio', 90 | 'event': 'incoming_session' 91 | 'silent': True 92 | 'reason': None 93 | "badge": "number" 94 | } 95 | ``` 96 | 97 | Where: 98 | 99 | * `app-id: str`, id provided by the mobile application (e.g. mobile bundle ID) 100 | * `platform: str`, 'firebase', 'android', 'apple' or 'ios' 101 | * `token: str`, destination device token, 102 | * *iOS device tokens* are strings with 64 hexadecimal symbols 103 | * *Android device push tokens* can differ in length`. 104 | * `device-id: str`, the device that generated the token 105 | * `call-id: str`, the unique session id for each call 106 | * `from: str`, address of the caller 107 | * `from_display_name`, (mandatory)*, display name of the caller 108 | * `to`, address of the callee 109 | * `media-type: str`: 'audio', 'video', 'chat', 'sms' or 'file-transfer' 110 | * `silent: bool`: *(optional, default `True`)* True for silent notification 111 | * `reason:str`: *(optional)* Cancel reason 112 | * `event: str`, type of event: 113 | * For *Sylk app* must be 'incoming_session', 'incoming_conference', 'cancel' or 'message' 114 | * For *Linphone app* must be 'incoming_session' 115 | * `badge: int`: optional badge to display 116 | 117 | The response is a json with the following structure: 118 | 119 | ``` 120 | { 121 | 'code': 'a numeric code equal to the HTTP response code', 122 | 'description': 'a detailed text description', 123 | 'data' : {} 124 | } 125 | ``` 126 | 127 | *data* contains an arbitrary dictionary with a structure depending on the 128 | request type and the remote server response. 129 | 130 | ### V2 131 | 132 | API version 2 supports storage of the push tokens in a Apache Cassandra Cluster 133 | or locally in a pickle file. The elements in the API methods are the same type 134 | and values as in API version 1. The API has the following methods: 135 | 136 | **POST** `/v2/tokens/{account}` - Stores a token for `{account}` 137 | ``` 138 | { 139 | "app-id": "string", 140 | "platform": "string", 141 | "token": "string", 142 | "device-id": "string", 143 | "silent": true, 144 | "user-agent": "string" 145 | } 146 | ``` 147 | 148 | **DELETE** `/v2/tokens/{account}` - Removes a token for `{account}` 149 | 150 | ``` 151 | { 152 | "app-id": "string", 153 | "device-id": "string" 154 | } 155 | ``` 156 | 157 | **POST** `/v2/tokens/{account}/push` - Sends a push notification(s) for `{account}` 158 | 159 | ``` 160 | { 161 | "event": "string", 162 | "call-id": "string", 163 | "from": "string", 164 | "from-display-name": "string", 165 | "to": "string", 166 | "media-type": "string", 167 | "reason": "string", 168 | "badge": "number" 169 | } 170 | ``` 171 | 172 | **POST** `/v2/tokens/{account}/push/{device}` - Sends a push notification for `{account}` and `{device}` 173 | 174 | ``` 175 | { 176 | "event": "string", 177 | "call-id": "string", 178 | "from": "string", 179 | "from-display-name": "string", 180 | "to": "string", 181 | "media-type": "string", 182 | "reason": "string", 183 | "badge": "number" 184 | } 185 | ``` 186 | 187 | ### Sample client code 188 | 189 | * See [sylk-pushclient](scripts/sylk-pushclient) 190 | * See [sylk-pushclient-v2](scripts/sylk-pushclient-v2) 191 | 192 | 193 | ### External APIs 194 | 195 | For documentation related to the API used by Apple and Firebase push 196 | notifications services you must consult their respective websites. For 197 | reference, the following APIs were used for developing the server, but these 198 | links may change: 199 | 200 | * [Sending Apple VoIp notifications](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns) 201 | * [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) 202 | * [FCM migration from legacy HTTP to HTTP v1](https://firebase.google.com/docs/cloud-messaging/migrate-v1) 203 | 204 | 205 | ### Apple Certificate 206 | 207 | Go to Apple developer website 208 | 209 | https://developer.apple.com/account/resources/identifiers/list 210 | 211 | Go to Identifiers section 212 | 213 | Select the app id 214 | 215 | Scroll down to Push notifications 216 | 217 | Click Configure 218 | 219 | Generate a certificate request using Keychain assistant. 220 | 221 | The private key will be saved in the Keys section of Keychain. 222 | 223 | Import the generated .cer certificate into Keychain. 224 | 225 | Export the certificate from the Keys section into pk12 format. 226 | 227 | Convert the cartificate and private key to .pem format: 228 | 229 | openssl pkcs12 -in Certificates.p12 -nocerts -out sylk.privateKey.pem 230 | openssl pkcs12 -in Certificates.p12 -clcerts -nokeys -out sylk.crt 231 | 232 | Remove the passcode from the private key: 233 | 234 | openssl rsa -in sylk.privateKey.pem -out sylk.key 235 | 236 | Use sylk.crt and sylk.key inside applications.ini config file. 237 | 238 | ### Apple token 239 | 240 | Go to Apple developer website 241 | 242 | https://developer.apple.com/account/authkeys/list 243 | 244 | Create a key for APNs and set your environment and restrictions. 245 | 246 | Download the p8 file and put the file in the credentials config dir and adjust 247 | applications.ini with the filename, key_id and team_id. 248 | 249 | ## Installation 250 | 251 | ### As a Debian/Ubuntu package 252 | 253 | Install the AG Projects debian software signing key: 254 | 255 | sudo curl -o /usr/share/keyrings/agp-debian-key.gpg https://download.ag-projects.com/agp-debian-key.gpg 256 | 257 | To use it for debian you should put the following in /etc/apt/sources.list.d/ag-projects.list. Substitute `__DISTRO__` with your distribution name, e.g. buster, bookworm... 258 | 259 | #### Debian 260 | 261 | 262 | ``` 263 | deb [signed-by=/usr/share/keyrings/agp-debian-key.gpg] https://packages.ag-projects.com/debian/ __DISTRO__ main contrib 264 | deb-src [signed-by=/usr/share/keyrings/agp-debian-key.gpg] https://packages.ag-projects.com/debian/ __DISTRO__ main contrib 265 | ``` 266 | 267 | #### Ubuntu 268 | 269 | To use it for ubuntu you should put the following in /etc/apt/sources.list.d/ag-projects.list. Substitute `__DISTRO__` with your distribution name, e.g. focal, jammy... 270 | 271 | ``` 272 | deb [signed-by=/usr/share/keyrings/agp-debian-key.gpg] https://packages.ag-projects.com/ubuntu/ __DISTRO__ main contrib 273 | deb-src [signed-by=/usr/share/keyrings/agp-debian-key.gpg] https://packages.ag-projects.com/ubuntu/ __DISTRO__ main contrib 274 | ``` 275 | 276 | Update the list of available packages: 277 | 278 | `sudo apt-get update` 279 | 280 | `sudo apt-get install sylk-pushserver` 281 | 282 | 283 | ### From source 284 | 285 | The source code is managed using darcs version control tool. The darcs 286 | repository can be fetched with: 287 | 288 | darcs clone http://devel.ag-projects.com/repositories/sylk-pushserver 289 | 290 | Alternatively, one can download a tar archive from: 291 | 292 | http://download.ag-projects.com/SylkPushserver/ 293 | 294 | Install Python dependencies: 295 | 296 | `pip3 install -r requirements.txt` 297 | 298 | `python3 setup.py install` 299 | 300 | 301 | ### Building Debian package 302 | 303 | Install building dependencies: 304 | 305 | ``` 306 | sudo apt install dh-virtualenv debhelper libsystemd-dev dh-python python3-dev python3-setuptools python3-pip 307 | ``` 308 | 309 | Build the package: 310 | 311 | ``` 312 | python setup.py sdist 313 | cd dist 314 | tar zxvf *.tar.gz 315 | cd sylk_pushserver-?.?.? 316 | debuild 317 | ``` 318 | 319 | To install the debian package manually: 320 | 321 | ``` 322 | sudo dpkg -i sylk-pushserver_1.0.0_all.deb 323 | sudo apt --fix-broken install 324 | ``` 325 | ## Configuration 326 | 327 | There are two configurations files. 328 | 329 | * general.ini 330 | 331 | Contains the general server settings. 332 | 333 | * applications.ini 334 | 335 | Contains the settings for each mobile application, *see 336 | config/applications.ini.sample*. Chages to this file cause the server to 337 | autamtically reload it, there is no need to restart the server. 338 | 339 | 340 | ## Remote logging 341 | 342 | Remote logging is done using a POST request over HTTP with a json containg 343 | both the original request and the final response of the push notification. 344 | 345 | ```{ 346 | 'request': push_request, 347 | 'response': push_response 348 | } 349 | ``` 350 | 351 | Where : 352 | 353 | * push_request is the original json payload received by this server 354 | * push_response is a json with the following format: 355 | 356 | ```{ 357 | 'code': code, # http response code from PNS 358 | 'description': description, # detail description of the response from the PNS 359 | 'push_url': push_url, # the final URL of the outgoing push notification 360 | 'incoming_body': {...}, # the original request body received by the server 361 | 'outgoing_headers': {...}. # the outgoing request headers sent to the PNS 362 | 'outgoing_body': {...} # the outgoing request body sent to the PNS 363 | } 364 | ``` 365 | 366 | The returned result should be a json with a consistent key. The key can be 367 | defined in the application.ini for each application. If the key is set then 368 | its value will be logged which can make troubleshooting easier. 369 | 370 | 371 | ## Custom applications 372 | 373 | Custom applications can be written in Python by subclassing existing template classes. 374 | 375 | Define the directory for custom applications in `general.ini` file: 376 | 377 | `extra_applications_dir` = `/etc/sylk-pushserver/applications` 378 | 379 | Copy config/applications/myapp.py to the extra_applications_dir and 380 | overwrite its functions. 381 | 382 | In `applications.ini` file set app_type for the custom applications: 383 | 384 | ``` 385 | `app_type` = *myapp* 386 | ``` 387 | 388 | ## Custom Push services 389 | 390 | Custom PNS can be written in Python by subclassing existing template classes. 391 | 392 | Define the directory for custom push services in `general.ini` file: 393 | 394 | `extra_pns_dir` = `/etc/sylk-pushserver/pns` 395 | 396 | Copy config/pns/mypns.py to the extra_pns dir and overwrite its classes. 397 | 398 | In `applications.ini` file set app_type for the custom applications: 399 | 400 | ``` 401 | `app_platform` = *mypns* 402 | ``` 403 | 404 | 405 | ## Running the server 406 | 407 | ### From the source code 408 | 409 | `./sylk-pushserver --config_dir ` 410 | 411 | If the config_dir directory is not specified, the following paths are searched for: 412 | 413 | * /etc/sylk-pushserver 414 | *./config 415 | 416 | For more command line options use -h. 417 | 418 | 419 | ### Debian package 420 | 421 | ``` 422 | sudo systemctl start sylk-pushserver 423 | ``` 424 | 425 | ### Testing 426 | 427 | For testing the server scripts/sylk-pushclient can be used. 428 | 429 | 430 | ## Compatibility 431 | 432 | The server is developed in Python 3 and was tested on Debian Buster 10. 433 | 434 | 435 | ## Reporting bugs 436 | 437 | You may report bugs to [SIP Beyond VoIP mailing list](http://lists.ag-projects.com/pipermail/sipbeyondvoip/) 438 | 439 | -------------------------------------------------------------------------------- /pushserver/resources/utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import logging 4 | import socket 5 | import ssl 6 | import time 7 | 8 | from ipaddress import ip_address 9 | 10 | __all__ = ['callid_to_uuid', 'fix_non_serializable_types', 'resources_available', 'ssl_cert', 'try_again', 'check_host', 11 | 'log_event', 'fix_device_id', 'fix_platform_name', 'log_incoming_request'] 12 | 13 | 14 | def callid_to_uuid(call_id: str) -> str: 15 | """ 16 | 17 | Generate a UUIDv4 from a callId. 18 | 19 | UUIDv4 format: five segments of seemingly random hex data, 20 | beginning with eight hex characters, followed by three 21 | four-character strings, then 12 characters at the end. 22 | These segments are separated by a “-”. 23 | 24 | :param call_id: `str` Globally unique identifier of a call. 25 | :return: a str with a uuidv4. 26 | """ 27 | hexa = hashlib.md5(call_id.encode()).hexdigest() 28 | 29 | uuidv4 = '%s-%s-%s-%s-%s' % \ 30 | (hexa[:8], hexa[8:12], hexa[12:16], hexa[16:20], hexa[20:]) 31 | 32 | return uuidv4 33 | 34 | 35 | def fix_non_serializable_types(obj): 36 | """ 37 | Converts a non serializable object in an appropriate one, 38 | if it is possible and in a recursive way. 39 | If not, return the str 'No JSON Serializable object' 40 | 41 | :param obj: obj to convert 42 | """ 43 | if isinstance(obj, bytes): 44 | string = obj.decode() 45 | return fix_non_serializable_types(string) 46 | 47 | elif isinstance(obj, dict): 48 | return { 49 | fix_non_serializable_types(k): fix_non_serializable_types(v) 50 | for k, v in obj.items() 51 | } 52 | 53 | elif isinstance(obj, (tuple, list)): 54 | return [fix_non_serializable_types(elem) for elem in obj] 55 | 56 | elif isinstance(obj, str): 57 | try: 58 | dict_obj = json.loads(obj) 59 | return fix_non_serializable_types(dict_obj) 60 | except json.decoder.JSONDecodeError: 61 | return obj 62 | 63 | elif isinstance(obj, (bool, int, float)): 64 | return obj 65 | 66 | else: 67 | return 68 | 69 | 70 | def resources_available(host: str, port: int) -> bool: 71 | """ 72 | Check if a pair ip, port is available for a connection 73 | :param: `str` host 74 | :param: `int` port 75 | :return: a `bool` according to the test result. 76 | """ 77 | serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 78 | if not host or not port: 79 | return None 80 | try: 81 | serversocket.bind((host, port)) 82 | serversocket.close() 83 | return True 84 | except OSError: 85 | return False 86 | 87 | 88 | def ssl_cert(cert_file: str, key_file=None) -> bool: 89 | """ 90 | Check if a ssl certificate is valid. 91 | :param cert_file: `str` path to certificate file 92 | :param key_file: `str` path to key file 93 | :return: `bool` True for a valid certificate. 94 | """ 95 | 96 | ssl_context = ssl.create_default_context() 97 | ssl_context.check_hostname = False 98 | ssl_context.verify_mode = ssl.CERT_NONE 99 | try: 100 | ssl_context.load_cert_chain(certfile=cert_file, keyfile=key_file) 101 | return True 102 | except (ssl.SSLError, NotADirectoryError, TypeError): 103 | return False 104 | 105 | 106 | def try_again(timer: int, host: str, port: int, 107 | start_error: str, loggers: dict) -> None: 108 | """ 109 | Sleep for a specific time and send log messages 110 | in case resources would not be available to start the app. 111 | :param timer: `int` time in seconds to wait (30 = DHCP delay) 112 | :param host: `str` IP address where app is trying to run 113 | :param port: `int` Host where app is trying to run 114 | :param start_error: `stṛ` Error msg to show in log. 115 | :param loggers: global logging instances to write messages (params.loggers) 116 | """ 117 | timer = timer # seconds, 30 for dhcp delay. 118 | level = 'error' 119 | msg = f"[can not init] on {host}:{port} - resources are not available" 120 | log_event(msg=start_error, level=level, loggers=loggers) 121 | log_event(msg=msg, level=level, loggers=loggers) 122 | msg = f'Server will try again in {timer} seconds' 123 | log_event(msg=msg, level=level, loggers=loggers) 124 | time.sleep(timer) 125 | 126 | 127 | def check_host(host, allowed_hosts) -> bool: 128 | """ 129 | Check if a host is in allowed_hosts 130 | :param host: `str` to check 131 | :return: `bool` 132 | """ 133 | if not allowed_hosts: 134 | return True 135 | 136 | for subnet in allowed_hosts: 137 | if ip_address(host) in subnet: 138 | return True 139 | 140 | return False 141 | 142 | 143 | def log_event(loggers: dict, msg: str, level: str = 'deb') -> None: 144 | """ 145 | Write log messages into log file and in journal if specified. 146 | :param loggers: `dict` global logging instances to write messages (params.loggers) 147 | :param msg: `str` message to write 148 | :param level: `str` info, error, deb or warn 149 | :param to_file: `bool` write just in file if True 150 | """ 151 | logger = loggers.get('to_journal') 152 | if logger.level != logging.DEBUG and loggers['debug'] is True: 153 | logger.setLevel(logging.DEBUG) 154 | elif logger.level != logging.INFO and loggers['debug'] is False: 155 | logger.setLevel(logging.INFO) 156 | 157 | if level == 'info': 158 | logger.info(msg) 159 | 160 | elif level == 'error': 161 | logger.error(msg) 162 | 163 | elif level == 'warn': 164 | logger.warning(msg) 165 | 166 | elif level in ('deb', 'debug'): 167 | logger.debug(msg) 168 | 169 | 170 | def fix_device_id(device_id_to_fix: str) -> str: 171 | """ 172 | Remove special characters from uuid 173 | :param device_id_to_fix: `str` uuid with special characters. 174 | :return: a `str` with fixed uuid. 175 | """ 176 | if '>' in device_id_to_fix: 177 | uuid = device_id_to_fix.split(':')[-1].replace('>', '') 178 | elif ':' in device_id_to_fix: 179 | uuid = device_id_to_fix.split(':')[-1] 180 | else: 181 | uuid = device_id_to_fix 182 | device_id = uuid 183 | return device_id 184 | 185 | 186 | def fix_platform_name(platform: str) -> str: 187 | """ 188 | Fix platform name in case its value is 'android' or 'ios', 189 | replacing it for 'firebase' and 'apple' 190 | :param platform: `str` name of platform 191 | :return: a `str` with fixed name. 192 | """ 193 | if platform in ('firebase', 'android', 'fcm'): 194 | return 'firebase' 195 | elif platform in ('apple', 'ios', 'apns'): 196 | return 'apple' 197 | else: 198 | return platform 199 | 200 | 201 | def fix_payload(body: dict) -> dict: 202 | payload = {} 203 | for item in body.keys(): 204 | value = body[item] 205 | if item in ('sip_to', 'sip_from'): 206 | item = item.split('_')[1] 207 | else: 208 | item = item.replace('_', '-') 209 | payload[item] = value 210 | return payload 211 | 212 | 213 | def pick_log_function(exc, *args, **kwargs): 214 | if ('rm_request' in exc.errors()[0]["loc"][1]): 215 | return log_remove_request(**kwargs) 216 | if ('add_request' in exc.errors()[0]["loc"][1]): 217 | return log_add_request(*args, **kwargs) 218 | else: 219 | return log_incoming_request(*args, **kwargs) 220 | 221 | 222 | def log_add_request(task: str, host: str, loggers: dict, 223 | request_id: str = None, body: dict = None, 224 | error_msg: str = None) -> None: 225 | """ 226 | Send log messages according to type of event. 227 | :param task: `str` type of event to log, can be 228 | 'log_request', 'log_success' or 'log_failure' 229 | :param host: `str` client host where request comes from 230 | :param loggers: `dict` global logging instances to write messages (params.loggers) 231 | :param request_id: `str` request ID generated on request 232 | :param body: `dict` body of request 233 | :param error_msg: `str` to show in log 234 | """ 235 | if task == 'log_request': 236 | payload = fix_payload(body) 237 | level = 'info' 238 | msg = f'{host} - Add Token - Request [{request_id}]: ' \ 239 | f'{payload}' 240 | log_event(msg=msg, level=level, loggers=loggers) 241 | 242 | elif task == 'log_success': 243 | payload = fix_payload(body) 244 | msg = f'{host} - Add Token - Response [{request_id}]: ' \ 245 | f'{payload}' 246 | level = 'info' 247 | log_event(msg=msg, level=level, loggers=loggers) 248 | 249 | elif task == 'log_failure': 250 | level = 'error' 251 | resp = error_msg 252 | msg = f'{host} - Add Token Failed - Response [{request_id}]: ' \ 253 | f'{resp}' 254 | log_event(loggers=loggers, msg=msg, level=level) 255 | 256 | 257 | def log_remove_request(task: str, host: str, loggers: dict, 258 | request_id: str = None, body: dict = None, 259 | error_msg: str = None) -> None: 260 | """ 261 | Send log messages according to type of event. 262 | :param task: `str` type of event to log, can be 263 | 'log_request', 'log_success' or 'log_failure' 264 | :param host: `str` client host where request comes from 265 | :param loggers: `dict` global logging instances to write messages (params.loggers) 266 | :param request_id: `str` request ID generated on request 267 | :param body: `dict` body of request 268 | :param error_msg: `str` to show in log 269 | """ 270 | if task == 'log_request': 271 | payload = fix_payload(body) 272 | level = 'info' 273 | msg = f'{host} - Remove Token - Request [{request_id}]: ' \ 274 | f'{payload}' 275 | log_event(msg=msg, level=level, loggers=loggers) 276 | 277 | elif task == 'log_success': 278 | payload = fix_payload(body) 279 | msg = f'{host} - Remove Token - Response [{request_id}]: ' \ 280 | f'{payload}' 281 | level = 'info' 282 | log_event(msg=msg, level=level, loggers=loggers) 283 | 284 | elif task == 'log_failure': 285 | level = 'error' 286 | resp = error_msg 287 | msg = f'{host} - Remove Token Failed - Response {request_id}: ' \ 288 | f'{resp}' 289 | log_event(loggers=loggers, msg=msg, level=level) 290 | 291 | 292 | def log_push_request(task: str, host: str, loggers: dict, 293 | request_id: str = None, body: dict = None, 294 | error_msg: str = None) -> None: 295 | """ 296 | Send log messages according to type of event. 297 | :param task: `str` type of event to log, can be 298 | 'log_request', 'log_success' or 'log_failure' 299 | :param host: `str` client host where request comes from 300 | :param loggers: `dict` global logging instances to write messages (params.loggers) 301 | :param request_id: `str` request ID generated on request 302 | :param body: `dict` body of request 303 | :param error_msg: `str` to show in log 304 | """ 305 | sip_to = body.get('to') 306 | event = body.get('event') 307 | 308 | if task == 'log_request': 309 | payload = fix_payload(body) 310 | level = 'info' 311 | msg = f'{host} - Push - Request [{request_id}]: ' \ 312 | f'{event} for {sip_to} ' \ 313 | f': {payload}' 314 | log_event(msg=msg, level=level, loggers=loggers) 315 | 316 | elif task == 'log_failure': 317 | level = 'error' 318 | resp = error_msg 319 | msg = f'{host} - Push Failed - Response [{request_id}]: ' \ 320 | f'{resp}' 321 | log_event(loggers=loggers, msg=msg, level=level) 322 | 323 | 324 | def log_incoming_request(task: str, host: str, loggers: dict, 325 | request_id: str = None, body: dict = None, 326 | error_msg: str = None) -> None: 327 | """ 328 | Send log messages according to type of event. 329 | :param task: `str` type of event to log, can be 330 | 'log_request', 'log_success' or 'log_failure' 331 | :param host: `str` client host where request comes from 332 | :param loggers: `dict` global logging instances to write messages (params.loggers) 333 | :param request_id: `str` request ID generated on request 334 | :param body: `dict` body of request 335 | :param error_msg: `str` to show in log 336 | """ 337 | app_id = body.get('app_id') 338 | platform = body.get('platform') 339 | platform = platform if platform else '' 340 | sip_to = body.get('sip_to') 341 | device_id = body.get('device_id') 342 | device_id = fix_device_id(device_id) if device_id else None 343 | event = body.get('event') 344 | 345 | if task == 'log_request': 346 | payload = fix_payload(body) 347 | level = 'info' 348 | if sip_to: 349 | if device_id: 350 | msg = f'incoming {platform.title()} request {request_id}: ' \ 351 | f'{event} for {sip_to} using' \ 352 | f' device {device_id} from {host}: {payload}' 353 | else: 354 | msg = f'incoming {platform.title()} request {request_id}: ' \ 355 | f'{event} for {sip_to} ' \ 356 | f'from {host}: {payload}' 357 | elif device_id: 358 | msg = f'incoming {platform.title()} request {request_id}: ' \ 359 | f'{event} using' \ 360 | f' device {device_id} from {host}: {payload}' 361 | else: 362 | msg = f'incoming {platform.title()} request {request_id}: ' \ 363 | f' from {host}: {payload}' 364 | log_event(msg=msg, level=level, loggers=loggers) 365 | 366 | elif task == 'log_success': 367 | msg = f'incoming {platform.title()} response for {request_id}: ' \ 368 | f'push accepted' 369 | level = 'info' 370 | log_event(msg=msg, level=level, loggers=loggers) 371 | 372 | elif task == 'log_failure': 373 | level = 'error' 374 | resp = error_msg 375 | msg = f'incoming {platform.title()} from {host} response for {request_id}, ' \ 376 | f'push rejected: {resp}' 377 | log_event(loggers=loggers, msg=msg, level=level) 378 | --------------------------------------------------------------------------------