diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..158bb8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +Copyright (C) 2020 AG Projects (http://ag-projects.com) + +License: GPL-3+ + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + For a copy of the license see https://www.gnu.org/licenses/gpl.html diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..dbc5ade --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,30 @@ +include LICENSE +include README.md +include MANIFEST.in + +include __info__.py +recursive-include config *.sample +recursive-include config *.py +include config/opensips.cfg + +include scripts/sylk-pushclient +include debian-requirements.txt +include requirements.txt + +include debian/changelog +include debian/compat +include debian/control +include debian/copyright +include debian/rules +include debian/docs +include debian/dirs +include debian/source/format +include debian/source/lintian-overrides +include debian/*links +include debian/*install +include debian/*logrotate +include debian/*service +include debian/*examples + +include *.py +recursive-include pushserver *.py \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6552dbe --- /dev/null +++ b/README.md @@ -0,0 +1,307 @@ + +# Sylk Pushserver + +[Home page](http://sylkserver.com) + +Copyright (C) 2020 AG Projects + +Sylk Pushserver was designed to act as a central dispatcher for mobile push +notifications inside RTC provider infrastructures. Both the provider and +the mobile application customer, in the case of a shared infrastructure, can +easily audit problems related to the processing of push notifications. + +Author: Bibiana Rivadeneira + + +## License + +Sylk Pushserver is licensed under GNU General Public License version 3. +[Copy of the license](http://www.fsf.org/licensing/licenses/gpl-3.0.html) + + +## Deployment scenarios + +Sylk Pushserver can be deployed together with WebRTC server applications or +VoIP servers like SIP Proxies and PBXs. Its main purpose is to act as a +central entity inside RTC provider infrastructures. Without such a +component, the same functionality must be built inside multiple servers and +as the number of mobile applications increases, the need for such central +component becomes obvious. + +### Integration examples + + * OpenSIPS: **config/opensips.cfg** + * SylkServer: built-in support + + +## Design + +Sylk Pushserver can handle an arbitrary number of different combinations of +push notification service and mobile applications. It and can be extended +by using Python programming language to support new push notification +services and applications. Sample applications are provided to handle Sylk +and Linphone mobile applications for Apple and Firebase push notification +services. + +For each configured Apple application, the server maintains a persistent +connection by using HTTP/2 over TLS 1.2 and reuses that connection for +sending notifications related to the application. Latest voip functionality +for iOS 13 or later is also suported. + +Each outgoing connection can use its own set of credentials, X.509 +certificates and urls. The connection failures are properly handled and +incoming requests remained queued for later by using a timer dependent on +the payload type. + + +### Logging + +All incoming and outgoing requests, including HTTP headers and bodies, can +be logged for troubleshooting purposes in the system journal and in a +separate log file. These logs can easily be correlated with the logs from +the server that generated the request by using the call-id key. + +Remote HTTP logging of the results is possible so that one or more +third-parties can receive information about the individual push requests and +responses for each application. + + +## API + +Sylk Pushserver expects a json over HTTP POST requests and translates it +into a correspondent outgoing push notifications request to Apple Push +Notifications or Firebase FCM servers. + +Json object structure: + +```{ +'app-id': 'com.agprojects.sylk-ios', +'platform': 'apple', +'token': '6688-71a883fe', +'device-id': 'accc8375125582aae062353', +'call-id': '4dbe8-7a53-42bd-95f3-9a7d43938', +'from': 'alice@example.com', +'from_display_name': 'Alice', +'to': 'bob@biloxi.com', +'media-type':'audio', +'event': 'incoming_session' +'silent': True +} +``` + +Where: + +* `app-id: str`, id provided by the mobile application (e.g. mobile bundle ID) +* `platform: str`, 'firebase', 'android', 'apple' or 'ios' +* `token: str`, destination device token, + * *iOS device tokens* are strings with 64 hexadecimal symbols + * *Android device push tokens* can differ in length`. +* `device-id: str`, the device that generated the token +* `call-id: str`, the unique session id for each call +* `from: str`, address of the caller +* `from_display_name`, (mandatory)*, display name of the caller +* `to`, address of the callee +* `media-type: str`: 'audio', 'video', 'chat', 'sms' or 'file-transfer' +* `silent: bool`: *(optional, default `True`)* True for silent notification +* `event: str`, type of event: + * For *Sylk app* must be 'incoming_session', 'incoming_conference' or 'cancel' + * For *Linphone app* must be 'incoming_session' + +The response is a json with the following structure: + +```{ +'code': 'a numeric code equal to the HTTP response code', +'description': 'a detailed text description', +'data' : {} +} +``` + +*data* contains an arbitrary dictionary with a structure depending on the +request type and the remote server response. + +### Sample client code + +See *scripts/sylk-pushclient* + + +### External APIs + +For documentation related to the API used by Apple and Firebase push +notifications services you must consult their respective websites. For +reference, the following APIs were used for developing the server, but these +links may change: + + * [Sending Apple VoIp notifications](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns) + * [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) + + +## Installation + +### As a Debian package + +Install the AG Projects debian software signing key: + +wget http://download.ag-projects.com/agp-debian-gpg.key + +sudo apt-key add agp-debian-gpg.key + +Add these repository matching your distribution to /etc/apt/sources.list: + +http://projects.ag-projects.com/projects/documentation/wiki/Repositories + +Update the list of available packages: + +sudo apt-get update + +sudo apt-get install sylk-pushserver + + +### From source + +Install Python dependencies: + +`pip3 install -r requirements.txt` + +`python3 setup.py install` + + +### Building Debian package + + +Install building dependencies: + +``` +sudo apt install dh-virtualenv debhelper libsystemd-dev dh-python python3-dev python3-setuptools python3-pip +``` + +Build the package: + +``` +python setup.py sdist +cd dist +tar zxvf *.tar.gz +cd sylk_pushserver-?.?.? +debuild +``` + +To install the debian package manually: + +``` +sudo dpkg -i sylk-pushserver_1.0.0_all.deb +sudo apt --fix-broken install +``` + +## Configuration + +There are two configurations files. + + * general.ini + +Contains the general server settings. + + * applications.ini + +Contains the settings for each mobile application, *see +config/applications.ini.sample*. Chages to this file cause the server to +autamtically reload it, there is no need to restart the server. + + +## Remote logging + +Remote logging is done using a POST request over HTTP with a json containg +both the original request and the final response of the push notification. + +```{ +'request': push_request, +'response': push_response +} +``` + +Where : + + * push_request is the original json payload received by this server + * push_response is a json with the following format: + +```{ +'code': code, # http response code from PNS +'description': description, # detail description of the response from the PNS +'push_url': push_url, # the final URL of the outgoing push notification +'incoming_body': {...}, # the original request body received by the server +'outgoing_headers': {...}. # the outgoing request headers sent to the PNS +'outgoing_body': {...} # the outgoing request body sent to the PNS +} +``` + +The returned result should be a json with a consistent key. The key can be +defined in the application.ini for each application. If the key is set then +its value will be logged which can make troubleshooting easier. + + +## Custom applications + +Custom applications can be written in Python by subclassing existing template classes. + +Define the directory for custom applications in `general.ini` file: + + `extra_applications_dir` = `/etc/sylk-pushserver/applications` + +Copy config/applications/myapp.py to the extra_applications_dir and +overwrite its functions. + +In `applications.ini` file set app_type for the custom applications: + +``` +`app_type` = *myapp* +``` + +## Custom Push services + +Custom PNS can be written in Python by subclassing existing template classes. + +Define the directory for custom push services in `general.ini` file: + + `extra_pns_dir` = `/etc/sylk-pushserver/pns` + +Copy config/pns/mypns.py to the extra_pns dir and overwrite its classes. + +In `applications.ini` file set app_type for the custom applications: + +``` +`app_platform` = *mypns* +``` + + +## Running the server + +### From the source code + +`./sylk-pushserver --config_dir ` + +If the config_dir directory is not specified, the following paths are searched for: + + * /etc/sylk-pushserver + *./config + +For more command line options use -h. + + +### Debian package + +``` +sudo systemctl start sylk-pushserver +``` + +### Testing + +For testing the server scripts/sylk-pushclient can be used. + + +## Compatibility + +The server is developed in Python 3 and was tested on Debian Buster 10. + + +## Reporting bugs + +You may report bugs to [SIP Beyond VoIP mailing list](http://lists.ag-projects.com/pipermail/sipbeyondvoip/) + diff --git a/__info__.py b/__info__.py new file mode 100644 index 0000000..d46ca05 --- /dev/null +++ b/__info__.py @@ -0,0 +1,10 @@ +"""Package information""" + +__version__ = '1.0.0' +__project__ = 'sylk_pushserver' +__summary__ = 'Mobile push notifications for RTC infrastructures' +__webpage__ = 'http://sylkserver.com' +__author__ = 'Bibiana Rivadeneira' +__email__ = 'support@ag-projects.com' +__license__ = 'GPL v3' +__copyright__ = 'Copyright 2020 AG Projects' diff --git a/config/applications.ini.sample b/config/applications.ini.sample new file mode 100644 index 0000000..50547b7 --- /dev/null +++ b/config/applications.ini.sample @@ -0,0 +1,88 @@ +; this file contains mobile applications +; the unique key is (app_id, platform, app_type) + +[myapp-apple] +; app_id = com.agprojects.sylk-ios + +; application type must be defince in the code, curently linphone and sylk +; are supported +; app_type = sylk + +; platform can be apple or firebase +; app_platform = apple + +; these are apple platform related settings +; apple_certificate = credentials/com.myapp-ios.pem +; apple_key = credentials/com.myapp-ios.key.pem +; apple_push_url = api.sandbox.push.apple.com + +; if voip is True, different headers will be generated for the push request, +; consult Apple documentation for more details. +; voip = True + +; log the requests for remote logging +; log_remote_urls = https://myapp.net, https://example.com + +; if log_remote_urls is set, a POST will be executed to the urls containing +; the original request and the final response,: +; payload = {'request': push_request, 'response': push_response} +; see readme.md for a detail description + + +; log_remote_timeout = 5 + +; if the key below is defined, the logline will contain the value of the +; key present in the json returned by the remote log server, if not key is +; defined the entire body will be logged if debub is True, otherwise only +; the response code will be logged +; log_remote_key = message + + +[myapp-firebase] +; app_id = com.agprojects.sylk +; app_type = sylk +; app_platform = firebase +; firebase_authorization_file = credentials/myapp-xxxxx-firebase-adminsdk-xxxxx-xxxxxxxx.json +; firebase_push_url = https://fcm.googleapis.com/v1/projects/myapp-xxxxx/messages:send + +; log the requests for remote logging +; log_remote_urls = https://myapp.net, https://example.com + +; if log_remote_urls is set, a POST will be executed to the urls containing +; the original request and the final response,: +; payload = {'request': push_request, 'response': push_response} +; see readme.md for a detail description + + +; log_remote_timeout = 5 + +; if the key below is defined, the logline will contain the value of the +; key present in the json returned by the remote log server, if not key is +; defined the entire body will be logged if debub is True, otherwise only +; the response code will be logged +; log_remote_key = message + + +[myapp-firebase2] +; app_id = myapp +; app_type = linphone +; app_platform = firebase +; firebase_authorization_key = ****** +; firebase_push_url = https://fcm.googleapis.com/fcm/send + +; log the requests for remote logging +; log_remote_urls = https://myapp.net, https://example.com + +; if log_remote_urls is set, a POST will be executed to the urls containing +; the original request and the final response,: +; payload = {'request': push_request, 'response': push_response} +; see readme.md for a detail description + + +; log_remote_timeout = 5 + +; if the key below is defined, the logline will contain the value of the +; key present in the json returned by the remote log server, if not key is +; defined the entire body will be logged if debub is True, otherwise only +; the response code will be logged +; log_remote_key = message \ No newline at end of file diff --git a/config/applications/__init__.py b/config/applications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/applications/myapp.py b/config/applications/myapp.py new file mode 100644 index 0000000..34288a1 --- /dev/null +++ b/config/applications/myapp.py @@ -0,0 +1,82 @@ + + +# To create a new app, import base classes from: + +# this app is based on existing sylk app +# import json +# from pushserver.applications.sylk import * + +# this app is based on existing linphone app +# import json +# from pushserver.applications.linphone import * + +# this app is based on the base app +import json +from pushserver.applications.apple import * +from pushserver.applications.firebase import * + + +__all__ = ['AppleMyappHeaders', 'AppleMyappPayload', + 'FirebaseMyappHeaders', 'FirebaseMyappPayload'] + + +class AppleMyappHeaders(AppleHeaders): + """ + An Apple headers structure for a push notification + + + @property + def headers(self): + """ + # Return: a valid dict of headers + """ + data = {} + headers = json.dumps(data) + return headers + """ + + +class AppleMyappPayload(ApplePayload): + """ + An Apple headers structure for a push notification + + @property + def payload(self) -> dict: + """ + # Return a valid payload: + """ + data = {} + payload = json.dumps(data) + return payload + """ + + +class FirebaseMyappHeaders(FirebaseHeaders): + """ + Firebase headers for a push notification + + + @property + def headers(self): + """ + # Return: a valid dict of headers + """ + data = {} + headers = json.dumps(data) + return headers + """ + + +class FirebaseMyAppPayload(FirebasePayload): + """ + A payload for a Firebase push notification + + @property + def payload(self) -> dict: + """ + # Return a valid payload: + """ + data = {} + payload = json.dumps(data) + return payload + """ \ No newline at end of file diff --git a/config/general.ini.sample b/config/general.ini.sample new file mode 100644 index 0000000..2a54e2a --- /dev/null +++ b/config/general.ini.sample @@ -0,0 +1,47 @@ +; The values after the ; are the default values, uncomment them only if you +; want to make changes + +[server] +; host = 0.0.0.0 +; port = 8400 + +; The file containing X.509 certificate and private key in unencrypted format +; If a certificate is set, the server will listen using TLS +; tls_certificate = '' + +; by default the server will respond to the client after the outgoing +; request for the push notification is completed. If false, the server will +; reply imediately with 202. The result of the push notification can then +; be found only in the logs. This is designed for client that can block and +; cannot or do not want to wait for the push operation to be completed +; return_async = true + +; by default any client is allowed to send requests to the server +; IP addresses and networks in CIDR notation are supported +; e.g: 10.10.10.0/24, 127.0.0.1, 192.168.1.2 +; allowed_hosts = [] + +; by default logs go to the journal; uncomment below to also log to a file +; log_file = /var/log/sylk-pushserver/push.log + +; If debug is true, headers and payloads for the outgoing requests will also +; be logged +; debug = False + +[applications] +; paths are relative to the config directory, by default /etc/sylk-pushserver +; and if missing ./config from the curent directory + +; mobile applications are configured in this file +; config_file = applications.ini + +; credentials relative paths are relative to this directory +; credentials_folder = credentials + +; more applications can be added to this directory +; extra_applications_dir = applications/ + +; more pns can be added to this directory +; extra_pns_dir = pns/ + + diff --git a/config/opensips.cfg b/config/opensips.cfg new file mode 100644 index 0000000..6d921a4 --- /dev/null +++ b/config/opensips.cfg @@ -0,0 +1,256 @@ + +# OpenSIPS configuration + +This is a configuration example for handling push notifications for Linphone +and Sylk Mobile applications. + +The token data generated by the device is sent as parameters to the Contact +header. Later, when an INVITE comes in, if a token is found for the called +party, a push notification is sent to each mobile device which will wake up +and register while the call is in progress. These late registrations will +cause OpenSIPS to fork the initial request to the newly registered contacts. + + +## SQL storage + +The token is saved into a MySQL table. + +``` +CREATE TABLE `push_tokens` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `username` char(64) NOT NULL DEFAULT '', + `domain` char(64) NOT NULL DEFAULT '', + `platform` char(64) NOT NULL DEFAULT '', + `app` char(255) NOT NULL DEFAULT '', + `token` char(255) NOT NULL DEFAULT '', + `sip_instance` char(255) NOT NULL DEFAULT '', + `user_agent` char(255) NOT NULL DEFAULT '', + `last_modified` datetime NOT NULL DEFAULT current_timestamp(), + `silent` char(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `token_idx` (`username`,`domain`,`sip_instance`,`app`) +); +``` + + +## Loaded modules + +``` +loadmodule "event_routing.so" +``` + +## Handle REGISTER route + +``` +route[REGISTER] { + ... + + # parse the Contact header paramaters or the Contact uri parameters of + # the register and save the push token to the database + + $avp(sip_instance) = $(ct.fields(params){param.value,+sip.instance}); + + if (!$avp(sip_instance)) { + $avp(sip_instance) = $(ct.fields(uri){uri.param,pn-device}); + } + + if ($avp(sip_instance)) { + # try to find if the device is mobile + $avp(pn_type) = $(ct.fields(uri){uri.param,pn-type}); + if (!$avp(pn_type)) { + $avp(pn_type) = $(ct.fields(uri){uri.param,pn-type}); + } + $avp(pn_app) = $(ct.fields(uri){uri.param,app-id}); + if (!$avp(pn_app)) { + $avp(pn_app) = $(ct.fields(uri){uri.param,pn-app}); + } + $avp(pn_token) = $(ct.fields(uri){uri.param,pn-tok}); + if (!$avp(pn_token)) { + $avp(pn_token) = $(ct.fields(uri){uri.param,pn-tok}); + } + $avp(pn_silent) = $(ct.fields(uri){uri.param,pn-silent}); + if (!$avp(pn_silent)) { + $avp(pn_silent) = $(ct.fields(uri){uri.param,pn-silent}); + } + if (!$avp(pn_silent)) { + $avp(pn_silent) = "0"; + } + } + + if (save("location")) { + # save data required later for push notifications + if ($avp(pn_type) and $avp(pn_token) and $avp(pn_app)) { + $avp(query) = "SELECT token from push_tokens where username = '" + $(tU{s.escape.common}) + "' and domain = '" + $(td{s.escape.common}) + "' and app + xlog("L_DBG", "[CONFIG] REGISTER push SQL query: $avp(query)\n"); + avp_db_query($avp(query), "$avp(old_pn_token)"); + if (not $avp(old_pn_token)) { + $avp(query) = "INSERT into push_tokens (username, domain, platform, app, token, sip_instance, user_agent, silent) values ('" + $(tU{s.escape.comm + xlog("L_DBG", "[CONFIG] REGISTER push SQL query: $avp(query)\n"); + avp_db_query($avp(query)); + } else { + $avp(query) = "UPDATE push_tokens set silent = '" + $(avp(pn_silent){s.escape.common}) + "', last_modified = NOW(), token = '" + $(avp(pn_token){ + xlog("L_DBG", "[CONFIG] REGISTER push SQL query: $avp(query)\n"); + avp_db_query($avp(query)); + } + } + } +} +``` + + +## Handle INVITE route + +``` +route[INVITE] { + ... + + $avp(sip_application_type) = "audio"; + + if (has_body("application/sdp")) { + if (search_body("m=audio")) { + $avp(sip_application_type) = "audio"; + } + if (search_body("m=video")) { + if (is_avp_set("$avp(sip_application_type)")) { + pv_printf($avp(sip_application_type), "$avp(sip_application_type), video"); + } else { + $avp(sip_application_type) = "video"; + } + } + if (search_body("(m=message).*?(MSRP)")) { + if (search_body("a=file-selector")) { + if (is_avp_set("$avp(sip_application_type)")) { + pv_printf($avp(sip_application_type), "$avp(sip_application_type), file-transfer"); + } else { + $avp(sip_application_type) = "file-transfer"; + } + } else { + if (is_avp_set("$avp(sip_application_type)")) { + pv_printf($avp(sip_application_type), "$avp(sip_application_type), chat"); + } else { + $avp(sip_application_type) = "chat"; + } + } + } + } + + # load push notifications tokens saved during register and wake up the devices + $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}) + "'"; + xlog("L_DBG", "[CONFIG] $avp(query)\n"); + + $var(i) = 0; + store_dlg_value("late_forking","0"); + + if (avp_db_query($avp(query), "$avp(pn_token);$avp(pn_app);$avp(pn_platform);$avp(sip_instance)")) { + $var(pn_event) = 'incoming_session'; + $var(from) = $fU + "@" + $fd; + $var(to) = $tU + "@" + $td; + $var(ci) = $ci; + + for ($var(pn_token) in $(avp(pn_token)[*])) { + $var(pn_app) = $(avp(pn_app)[$var(i)]); + $var(pn_platform) = $(avp(pn_platform)[$var(i)]); + $var(sip_instance) = $(avp(sip_instance)[$var(i)]); + $var(i) = $var(i) + 1; + + $avp(late_forking) = 1; + store_dlg_value("late_forking","1"); + store_dlg_value("sip_application_type",$avp(sip_application_type)); + $var(sip_application_type) = $avp(sip_application_type); + + # Launch method + # launch is a fire and forget mechanism, the script does not block or wait, it just continues + $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) + "\""; + xlog("L_INFO", "[CONFIG] Push notification command: $var(push_command)\n"); + launch(exec("$var(push_command)",, $var(rc)), "PN_RESULT"); + } + } + + if (not t_newtran()) { + sl_reply_error(); + exit; + } + + if ($avp(late_forking)) { + sl_send_reply(110, "Push sent"); + t_wait_for_new_branches(); + $avp(filter) = "aor="+$rU+"@"+$rd; + notify_on_event("E_UL_CONTACT_INSERT",$avp(filter), "LATE_FORKING", 40); + } + + if (lookup("location")) { + xlog("L_INFO", "[CONFIG] $rU@$rd has online devices for $rm ($ci)\n"); + } else { + if ($avp(late_forking)) { + xlog("L_INFO", "[CONFIG] $ru is not yet online ($ci)\n"); + } else { + sl_send_reply(480, "User not online"); + } + } +} +``` + +## Handle CANCEL route + +``` +route[CANCEL] { + ... + + if ($dlg_val(late_forking) == "1") { + $var(user) = $rU; + $var(domain) = $rd; + $var(sip_application_type) = $dlg_val(sip_application_type); + + $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}) + "'"; + xlog("L_DBG", "[CONFIG] $avp(query)\n"); + + if (avp_db_query($avp(query), "$avp(pn_token);$avp(pn_app);$avp(pn_platform);$avp(sip_instance)")) { + $var(i) = 0; + $var(pn_event) = 'cancel'; + $var(from) = $fU + "@" + $fd; + $var(to) = $tU + "@" + $td; + $var(ci) = $ci; + + for ($var(pn_token) in $(avp(pn_token)[*])) { + $var(pn_app) = $(avp(pn_app)[$var(i)]); + $var(pn_platform) = $(avp(pn_platform)[$var(i)]); + $var(sip_instance) = $(avp(sip_instance)[$var(i)]); + + $var(i) = $var(i) + 1; + + # Launch method + # launch is a fire and forget mechanism, the script does not block or wait, it just continues + $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) + "\""; + xlog("L_INFO", "[CONFIG] Push notification command: $var(push_command)\n"); + launch(exec("$var(push_command)",, $var(rc)), "PN_RESULT"); + } + } + } +} +``` + +## Handle late forking route + +``` +route[LATE_FORKING] { + # handle incoming calls for mobile devices woken up by push notifications + + INFO("$avp(aor) registered contact $avp(uri) while receiving an incoming call"); + # take the contact described by the E_UL_CONTACT_INSERT + # event and inject it as a new branch into the original + # transaction + t_inject_branches("event"); +} +``` + +## Handle push result route + +``` +route[PN_RESULT] { + if ($var(retcode) == "1") { + xlog("L_INFO", "[CONFIG] $var(pn_event) push notification for $var(to) on device $var(sip_instance) ($var(ci)) suceeded\n"); + } else { + xlog("L_INFO", "[CONFIG] $var(pn_event) push notification for $var(to) on device $var(sip_instance) ($var(ci)) failed\n"); + } +} +``` diff --git a/config/pns/__init__.py b/config/pns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/pns/mypns.py b/config/pns/mypns.py new file mode 100644 index 0000000..05c0cea --- /dev/null +++ b/config/pns/mypns.py @@ -0,0 +1,35 @@ + + +# To create a new app, import base classes from: + +# this app is based on existing apple pns +# from pushserver.pns.apple import * + +# this app is based on existing firebase pns +# from pushserver.pns.firebase import * + +# this app is based on the base pns +from pushserver.pns.base import * + + +__all__ = ['MyPnsPNS', 'MyPnsRegister'] + + +class MyPnsPNS(PNS): + """ + A Push Notification service + """ + + +class MyPnsRegister(PlatformRegister): + """ + A register with pns and other needed objects + + + @property + def register_entries(self): + + return {'pns': self.pns, + ..., + } +""" \ No newline at end of file diff --git a/debian-requirements.txt b/debian-requirements.txt new file mode 100644 index 0000000..b41506e --- /dev/null +++ b/debian-requirements.txt @@ -0,0 +1,4 @@ +fastapi == 0.52.0 +hyper >= 0.7.0 +pydantic >= 1.4 +pyinotify >= 0.9.6 diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..ae3911b --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +sylk-pushserver (1.0.0) unstable; urgency=low + + * First version of sylk-pushserver + + -- Adrian Georgescu Fri, 29 May 2020 15:16:07 +0200 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..9d60796 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +11 \ No newline at end of file diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..99cdab2 --- /dev/null +++ b/debian/control @@ -0,0 +1,31 @@ +Source: sylk-pushserver +Maintainer: AG Projects +Uploaders: Adrian Georgescu , Tijmen de Mes +Section: net +Priority: optional +Standards-Version: 4.3.0 +Build-Depends: debhelper (>= 11~), + python3 (>= 3.7), + dh-virtualenv (>= 1.0), + python3-setuptools, + python3-pip, + python3-dev, + rename + +X-Python3-Version: >= 3.7 +Package: sylk-pushserver +Architecture: any +Section: net +Depends: ${misc:Depends}, ${shlibs:Depends}, sensible-utils, + python3 (>= 3.7), + python3-oauth2client (>= 4.1.2), + python3-pyinotify (>= 0.9.6), + python3-requests (>= 2.21), + python3-systemd (>= 0.16.1), + python3-uvicorn (>= 0.3) +Enhances: sylkserver-webrtc-gateway +Description: Mobile push notifications for RTC infrastructures + Sylk Pushserver was designed to act as a central dispatcher for mobile push + notifications inside RTC provider infrastructures. Both the provider and + the mobile application customer, in the case of a shared infrastructure, + can easily audit problems related to the processing of push notifications. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..d363654 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,15 @@ +Copyright 2020 AG Projects (http://ag-projects.com) + +License: GPL-3+ + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + For a copy of the license see /usr/share/common-licenses/GPL-3 diff --git a/debian/dirs b/debian/dirs new file mode 100644 index 0000000..efdd950 --- /dev/null +++ b/debian/dirs @@ -0,0 +1,6 @@ +/etc/sylk-pushserver/ +/etc/sylk-pushserver/applications +/etc/sylk-pushserver/pns +/etc/sylk-pushserver/credentials +/usr/lib +/var/log/sylk-pushserver diff --git a/debian/docs b/debian/docs new file mode 100644 index 0000000..b43bf86 --- /dev/null +++ b/debian/docs @@ -0,0 +1 @@ +README.md diff --git a/debian/logrotate b/debian/logrotate new file mode 100644 index 0000000..56433ce --- /dev/null +++ b/debian/logrotate @@ -0,0 +1,11 @@ +/var/log/sylk-pushserver/*.log +{ + rotate 7 + daily + delaycompress + compress + missingok + copytruncate + nocreate +} + diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..d16c14f --- /dev/null +++ b/debian/rules @@ -0,0 +1,23 @@ +#!/usr/bin/make -f + +export DH_VERBOSE = 1 +export SETUPTOOLS_DEB_LAYOUT = 1 +export DH_VIRTUALENV_INSTALL_ROOT = /usr/lib/python3/dist-packages +export DH_VIRTUALENV_ARGUMENTS := --python python3 +export DH_REQUIREMENTS_FILE := debian-requirements.txt + +%: + dh $@ --buildsystem dh_virtualenv + +override_dh_clean: + dh_clean + rm -rf build dist MANIFEST + +override_dh_install: + dh_install + rm debian/sylk-pushserver/usr/lib/python3/dist-packages/sylk-pushserver/lib/python3.7/site-packages/setuptools/*exe + py3clean . + + +override_dh_installsystemd: + dh_installsystemd -psylk-pushserver --name=sylk-pushserver --no-start diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/debian/source/lintian-overrides b/debian/source/lintian-overrides new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/debian/source/lintian-overrides @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/debian/sylk-pushserver.examples b/debian/sylk-pushserver.examples new file mode 100644 index 0000000..7cff10d --- /dev/null +++ b/debian/sylk-pushserver.examples @@ -0,0 +1,6 @@ +config/applications.ini.sample +config/general.ini.sample +config/opensips.cfg +config/pns +config/applications + diff --git a/debian/sylk-pushserver.install b/debian/sylk-pushserver.install new file mode 100644 index 0000000..55c580d --- /dev/null +++ b/debian/sylk-pushserver.install @@ -0,0 +1,3 @@ +config/*.ini.sample etc/sylk-pushserver/ +config/applications/*.py /etc/sylk-pushserver/applications/ +config/pns/*.py /etc/sylk-pushserver/pns/ diff --git a/debian/sylk-pushserver.links b/debian/sylk-pushserver.links new file mode 100644 index 0000000..03421f2 --- /dev/null +++ b/debian/sylk-pushserver.links @@ -0,0 +1,2 @@ +usr/lib/python3/dist-packages/sylk-pushserver/bin/sylk-pushserver usr/bin/sylk-pushserver +usr/lib/python3/dist-packages/sylk-pushserver/bin/sylk-pushclient usr/bin/sylk-pushclient diff --git a/debian/sylk-pushserver.service b/debian/sylk-pushserver.service new file mode 100644 index 0000000..487cd98 --- /dev/null +++ b/debian/sylk-pushserver.service @@ -0,0 +1,10 @@ +[Unit] +Description=Sylk Push Notifications server +After=network.target + +[Service] +ExecStart=/usr/bin/sylk-pushserver --config_dir /etc/sylk-pushserver/ +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/makedeb.sh b/makedeb.sh new file mode 100755 index 0000000..39aa214 --- /dev/null +++ b/makedeb.sh @@ -0,0 +1,19 @@ +#!/bin/bash +if [ -f dist ]; then + rm -r dist +fi + +python setup.py sdist +sleep 2 + +cd dist + +tar zxvf *.tar.gz +cd sylk_pushserver-?.?.? +ls +sleep 3 + +debuild + +cd dist +ls diff --git a/pushserver/__init__.py b/pushserver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pushserver/api/__init__.py b/pushserver/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pushserver/api/errors/__init__.py b/pushserver/api/errors/__init__.py new file mode 100644 index 0000000..9207140 --- /dev/null +++ b/pushserver/api/errors/__init__.py @@ -0,0 +1 @@ +__all__ = ['validation_error'] \ No newline at end of file diff --git a/pushserver/api/errors/validation_error.py b/pushserver/api/errors/validation_error.py new file mode 100644 index 0000000..6f86311 --- /dev/null +++ b/pushserver/api/errors/validation_error.py @@ -0,0 +1,51 @@ +from typing import Union + +from fastapi import Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import ValidationError + +from pushserver.resources import settings +from pushserver.resources.utils import log_incoming_request + + +async def validation_exception_handler(request: Request, + exc: Union[RequestValidationError, + ValidationError]) -> JSONResponse: + host, port = request.scope['client'][0], request.scope['client'][1] + error_msg = None + code = 400 + status_code = status.HTTP_400_BAD_REQUEST + + for entry in exc.errors(): + if 'found' in entry['msg'] or 'configured' in entry['msg']: + status_code = status.HTTP_404_NOT_FOUND + code = 404 + + if not error_msg: + error_msg = exc.errors()[0]['msg'] + + try: + request_id = f"{exc.body['event']} - " \ + f"{exc.body['app-id']}-" \ + f"{exc.body['call-id']}" + except (KeyError, TypeError): + request_id = "unknown" + + log_incoming_request(task='log_request', host=host, + loggers=settings.params.loggers, + request_id=request_id, body=exc.body) + + log_incoming_request(task='log_failure', host=host, + loggers=settings.params.loggers, + request_id=request_id, body=exc.body, + error_msg=error_msg) + + content = jsonable_encoder({'code': code, + 'description': error_msg, + 'data': ''}) + + return JSONResponse( + status_code=status_code, + content=content) diff --git a/pushserver/api/routes/__init__.py b/pushserver/api/routes/__init__.py new file mode 100644 index 0000000..5431210 --- /dev/null +++ b/pushserver/api/routes/__init__.py @@ -0,0 +1 @@ +__all__ = ['api', 'home', 'push'] diff --git a/pushserver/api/routes/api.py b/pushserver/api/routes/api.py new file mode 100644 index 0000000..a11d396 --- /dev/null +++ b/pushserver/api/routes/api.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from pushserver.api.routes import home, push + +router = APIRouter() +router.include_router(home.router, tags=["welcome", "home"]) +router.include_router(push.router, tags=["push"], prefix="/push") diff --git a/pushserver/api/routes/home.py b/pushserver/api/routes/home.py new file mode 100644 index 0000000..70a3bdd --- /dev/null +++ b/pushserver/api/routes/home.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get('/') +async def welcome(): + return 'Welcome to sylk-push server' diff --git a/pushserver/api/routes/push.py b/pushserver/api/routes/push.py new file mode 100644 index 0000000..574505d --- /dev/null +++ b/pushserver/api/routes/push.py @@ -0,0 +1,81 @@ +import json + +from fastapi import APIRouter, BackgroundTasks, Request, status +from fastapi.responses import JSONResponse + +from pushserver.models.requests import WakeUpRequest, fix_platform_name +from pushserver.resources import settings +from pushserver.resources.notification import handle_request +from pushserver.resources.utils import (check_host, + log_event, log_incoming_request) + +router = APIRouter() + + +@router.post('', response_model=WakeUpRequest) +async def push_requests(request: Request, + wp_request: WakeUpRequest, + background_tasks: BackgroundTasks): + + wp_request.platform = fix_platform_name(wp_request.platform) + + host, port = request.client.host, request.client.port + + code, description, data = '', '', {} + + if check_host(host, settings.params.allowed_pool): + request_id = f"{wp_request.event}-{wp_request.app_id}-{wp_request.call_id}" + + if not settings.params.return_async: + background_tasks.add_task(log_incoming_request, task='log_request', + host=host, loggers=settings.params.loggers, + request_id=request_id, body=wp_request.__dict__) + + background_tasks.add_task(log_incoming_request, task='log_success', + host=host, loggers=settings.params.loggers, + request_id=request_id, body=wp_request.__dict__) + background_tasks.add_task(handle_request, + wp_request=wp_request, + request_id=request_id) + status_code, code = status.HTTP_202_ACCEPTED, 202 + description, data = 'accepted for delivery', {} + + try: + return JSONResponse(status_code=status_code, + content={'code': code, + 'description': description, + 'data': data}) + except json.decoder.JSONDecodeError: + return JSONResponse(status_code=status_code, + content={'code': code, + 'description': description, + 'data': {}}) + + else: + log_incoming_request(task='log_request', + host=host, loggers=settings.params.loggers, + request_id=request_id, body=wp_request.__dict__) + + log_incoming_request(task='log_success', + host=host, loggers=settings.params.loggers, + request_id=request_id, body=wp_request.__dict__) + results = handle_request(wp_request, request_id=request_id) + code = results.get('code') + description = 'push notification response' + data = results + + else: + msg = f'incoming request from {host} is denied' + log_event(loggers=settings.params.loggers, + msg=msg, level='deb') + code = 403 + description = 'access denied by access list' + data = {} + + if settings.params.loggers['debug']: + log_event(loggers=settings.params.loggers, + msg=msg, level='deb', to_file=True) + + return JSONResponse(status_code=code, content={'code': code, + 'description': description, + 'data': data}) diff --git a/pushserver/applications/__init__.py b/pushserver/applications/__init__.py new file mode 100644 index 0000000..7311349 --- /dev/null +++ b/pushserver/applications/__init__.py @@ -0,0 +1 @@ +__all__ = ['apple', 'firebase', 'linphone', 'sylk'] diff --git a/pushserver/applications/apple.py b/pushserver/applications/apple.py new file mode 100644 index 0000000..08a5349 --- /dev/null +++ b/pushserver/applications/apple.py @@ -0,0 +1,130 @@ +__all__ = ['AppleHeaders', 'ApplePayload'] + + +class AppleHeaders(object): + """ + Apple headers structure for a push notification + """ + + def __init__(self, app_id: str, event: str, token: str, + call_id: str, sip_from: str, from_display_name: str, + sip_to: str, media_type: str, silent: bool): + """ + :param app_id: `str` id provided by the mobile application (bundle id) + :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' + :param token: `str` destination device token. + :param call_id: `str` unique sip parameter. + _param sip_from: `str` SIP URI for who is calling + :param from_display_name: `str` display name of the caller + :param sip_to: `str` SIP URI for who is called + :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' + :param silent: `bool` True for silent notification + """ + + self.app_id = app_id + self.token = token + self.call_id = call_id + self.sip_from = sip_from + self.sip_to = sip_to + self.from_display_name = from_display_name + self.media_type = media_type + self.silent = silent + self.event = event + + self.apns_push_type = self.create_push_type() + self.apns_expiration = self.create_expiration() + self.apns_topic = self.create_topic() + self.apns_priority = self.create_priority() + + def create_push_type(self) -> str: + """ + logic to define apns_push_type value using request parameters + apns_push_type reflect the contents of the notification’s payload, + it can be: + 'alert', 'background', 'voip', + 'complication', 'fileprovider' or 'mdm'. + """ + return + + def create_expiration(self) -> int: + """ + logic to define apns_expiration value using request parameters + apns_expiration is the date at which the notification expires, + (UNIX epoch expressed in seconds UTC). + """ + return + + def create_topic(self) -> str: + """ + logic to define apns_topic value using request parameters + apns_topic is in general is the app’s bundle ID and may have + a suffix based on the notification’s type. + """ + return + + def create_priority(self) -> int: + """ + logic to define apns_priority value using request parameters + Notification priority, + apns_prioriy 10 o send the notification immediately, + 5 to send the notification based on power considerations + on the user’s device. + """ + return + + @property + def headers(self) -> dict: + """ + Generate apple notification headers + + :return: a `dict` object with headers. + """ + + headers = { + 'apns-push-type': self.apns_push_type, + 'apns-expiration': self.apns_expiration, + 'apns-priority': self.apns_priority, + 'apns-topic': self.apns_topic, + 'authorization': f"bearer {self.token}"} + + return headers + + +class ApplePayload(object): + """ + Apple payload structure for a push notification + """ + + def __init__(self, app_id: str, event: str, token: str, + call_id: str, sip_from: str, from_display_name: str, + sip_to: str, media_type, silent: bool): + """ + :param app_id: `str` id provided by the mobile application (bundle id) + :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' + :param token: `str` destination device token. + :param call_id: `str` unique sip parameter. + :param sip_from: `str` SIP URI for who is calling + :param from_display_name: `str` display name of the caller + :param sip_to: `str` SIP URI for who is called + :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' + :param silent: `bool` True for silent notification + """ + self.app_id = app_id + self.token = token + self.call_id = call_id + self.sip_from = sip_from + self.sip_to = sip_to + self.from_display_name = from_display_name + self.media_type = media_type + self.silent = silent + self.event = event + + @property + def payload(self) -> dict: + """ + logic to define apple payload using request parameters + """ + + payload = {} + return payload + diff --git a/pushserver/applications/firebase.py b/pushserver/applications/firebase.py new file mode 100644 index 0000000..2aa6e36 --- /dev/null +++ b/pushserver/applications/firebase.py @@ -0,0 +1,91 @@ +import json + +from pushserver.resources import settings + +__all__ = ['FirebaseHeaders', 'FirebasePayload'] + + +class FirebaseHeaders(object): + def __init__(self, app_id: str, event: str, token: str, + call_id: str, sip_from: str, from_display_name: str, + sip_to: str, media_type: str, silent: bool): + """ + :param app_id: `str` id provided by the mobile application (bundle id) + :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' + :param token: `str` destination device token. + :param call_id: `str` unique sip parameter. + :param sip_from: `str` SIP URI for who is calling + :param from_display_name: `str` display name of the caller + :param sip_to: `str` SIP URI for who is called + :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' + :param silent: `bool` True for silent notification + """ + + self.app_id = app_id + self.token = token + self.call_id = call_id + self.sip_from = sip_from + self.sip_to = sip_to + self.from_display_name = from_display_name + self.media_type = media_type + self.silent = silent + self.event = event + self.access_token = '' + + self.auth_key = \ + settings.params.pns_register[(self.app_id, 'firebase')]['auth_key'] + if not self.auth_key: + pns_dict = settings.params.pns_register[(self.app_id, 'firebase')]['pns'].__dict__ + self.access_token = pns_dict['access_token'] + + @property + def headers(self): + """ + Generate Firebase headers structure for a push notification + + :return: a firebase push notification header. + """ + if self.auth_key: + headers = {'Content-Type': 'application/json', + 'Authorization': f"key={self.auth_key}"} + else: + headers = { + 'Authorization': f"Bearer {self.access_token}", + 'Content-Type': 'application/json; UTF-8', + } + + return headers + + +class FirebasePayload(object): + def __init__(self, app_id: str, event: str, token: str, + call_id: str, sip_from: str, from_display_name: str, + sip_to: str, media_type: str, silent: bool): + """ + :param token: `str` destination device token (required for sylk-apple) + :param call_id: `str` unique SIP session id for each call + :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' + :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' + :param sip_from: `str` originator of the sip call. + :param from_display_name: `str` + :param sip_to: `str` destination uri + """ + self.app_id = app_id + self.token = token + self.call_id = call_id # corresponds to session_id in the output + self.event = event + self.media_type = media_type + self.sip_from = sip_from + self.from_display_name = from_display_name + self.sip_to = sip_to + self.silent = silent + + @property + def payload(self) -> dict: + """ + Generate a Firebase payload for a push notification + + :return a Firebase payload: + """ + payload = {} + return payload diff --git a/pushserver/applications/linphone.py b/pushserver/applications/linphone.py new file mode 100644 index 0000000..f46b938 --- /dev/null +++ b/pushserver/applications/linphone.py @@ -0,0 +1,127 @@ +from datetime import datetime + +from pushserver.applications.apple import * +from pushserver.applications.firebase import * + +__all__ = ['AppleLinphoneHeaders', 'AppleLinphonePayload', + 'FirebaseLinphoneHeaders', 'FirebaseLinphonePayload'] + + +class AppleLinphoneHeaders(AppleHeaders): + """ + An Apple headers structure for a push notification + """ + + def create_push_type(self) -> str: + """ + logic to define apns_push_type value using request parameters + apns_push_type reflect the contents of the notification’s payload, + it can be: + 'alert', 'background', 'voip', + 'complication', 'fileprovider' or 'mdm'. + """ + return 'voip' + + def create_expiration(self) -> int: + """ + logic to define apns_expiration value using request parameters + apns_expiration is the date at which the notification expires, + (UNIX epoch expressed in seconds UTC). + """ + return '10' + + def create_topic(self) -> str: + """ + Define a valid apns topic + based on app_id value, without 'prom' or 'dev' + + :return: a `str` with a valid apns topic + """ + + if self.app_id.endswith('.dev') or self.app_id.endswith('.prod'): + apns_topic = '.'.join(self.app_id.split('.')[:-1]) + else: + apns_topic = self.app_id + if not '.voip' in apns_topic: + apns_topic = f"{apns_topic}.voip" + + return apns_topic + + def create_priority(self) -> int: + """ + logic to define apns_priority value using request parameters + Notification priority, + apns_prioriy 10 o send the notification immediately, + 5 to send the notification based on power considerations + on the user’s device. + """ + return '10' + + +class FirebaseLinphoneHeaders(FirebaseHeaders): + """ + Firebase headers for a push notification + """ + + +class AppleLinphonePayload(ApplePayload): + """ + An Apple payload for a Linphone push notification + """ + + @property + def payload(self) -> dict: + """ + Generate apple notification payload + + :param silent: `bool` True for silent notification. + :return: A `json` with a push notification payload. + + """ + + now = datetime.now() + send_time = now.strftime('%Y-%m-%d %H:%M:%S') + + if self.silent: + payload = {'aps': {'sound': '', + 'loc-key': 'IC_SIL', + 'call-id': self.call_id, + 'send-time': send_time}, + 'pn_ttl': 2592000} + else: + payload = {'aps': {'alert': {'loc-key': 'IC_MSG', + 'loc-args': self.sip_from}, + 'sound': 'msg.caf', 'badge': 1}, + 'pn_ttl': 2592000, + 'call-id': self.call_id, + 'send-time': send_time} + + return payload + + +class FirebaseLinphonePayload(FirebasePayload): + """ + A Firebase payload for a Linphone push notification + """ + + @property + def payload(self) -> dict: + """ + Generate a Firebase payload for a push notification + + :return a Firebase payload: + """ + + now = datetime.now() + send_time = now.strftime('%Y-%m-%d %H:%M:%S') + + payload = {'to': self.token, + 'time_to_live': 2419199, + 'priority': 'high', + 'data': {'call-id': self.call_id, + 'sip-from': self.sip_from, + 'loc-key': '', + 'loc-args': self.sip_from, + 'send-time': send_time}} + return payload + diff --git a/pushserver/applications/sylk.py b/pushserver/applications/sylk.py new file mode 100644 index 0000000..883739c --- /dev/null +++ b/pushserver/applications/sylk.py @@ -0,0 +1,146 @@ +from pushserver.applications.apple import * +from pushserver.applications.firebase import * +from pushserver.resources.utils import callid_to_uuid + + +__all__ = ['AppleSylkHeaders', 'AppleSylkPayload', + 'FirebaseSylkHeaders', 'FirebaseSylkPayload'] + + +class AppleSylkHeaders(AppleHeaders): + """ + An Apple headers structure for a push notification + """ + + def create_push_type(self) -> str: + """ + logic to define apns_push_type value using request parameters + apns_push_type reflect the contents of the notification’s payload, + it can be: + 'alert', 'background', 'voip', + 'complication', 'fileprovider' or 'mdm'. + """ + push_type = 'voip' if self.event == 'incoming_session' else 'background' + + return push_type + + def create_expiration(self) -> int: + """ + logic to define apns_expiration value using request parameters + apns_expiration is the date at which the notification expires, + (UNIX epoch expressed in seconds UTC). + """ + return '120' + + def create_topic(self) -> str: + """ + logic to define apns_topic value using request parameters + apns_topic is in general is the app’s bundle ID and may have + a suffix based on the notification’s type. + """ + apns_topic = self.app_id + + if self.app_id.endswith('.dev') or self.app_id.endswith('.prod'): + apns_topic = '.'.join(self.app_id.split('.')[:-1]) + + if self.event == 'incoming_session': + apns_topic = f"{apns_topic}.voip" + + return apns_topic + + def create_priority(self) -> int: + """ + logic to define apns_priority value using request parameters + Notification priority, + apns_prioriy 10 o send the notification immediately, + 5 to send the notification based on power considerations + on the user’s device. + """ + apns_priority = '10' if self.event == 'incoming_session' else '5' + + return apns_priority + + +class FirebaseSylkHeaders(FirebaseHeaders): + """ + Firebase headers for a push notification + """ + + +class AppleSylkPayload(ApplePayload): + """ + A payload for a Apple Sylk push notification + """ + + @property + def payload(self) -> str: + """ + Generate an AppleSylk notification payload + """ + + if self.event == 'cancel': + payload = { + 'event': self.event, + 'call-id': self.call_id, + 'session-id': callid_to_uuid(self.call_id), + } + else: + payload = { + 'event': self.event, + 'call-id': self.call_id, + 'session-id': callid_to_uuid(self.call_id), + 'media-type': self.media_type, + 'from_uri': self.sip_from, + 'from_display_name': self.from_display_name, + 'to_uri': self.sip_to + } + + return payload + + +class FirebaseSylkPayload(FirebasePayload): + """ + A payload for a Firebase Sylk push notification + """ + + @property + def payload(self) -> str: + """ + Generate a Sylk payload and extra Firebase parameters + """ + + if not self.from_display_name: + from_display_name = self.sip_from + else: + from_display_name = self.from_display_name + + if self.event == 'cancel': + data = { + 'event': self.event, + 'call-id': self.call_id, + 'session-id': callid_to_uuid(self.call_id), + } + else: + data = { + 'event': self.event, + 'call-id': self.call_id, + 'session-id': callid_to_uuid(self.call_id), + 'media-type': self.media_type, + 'from_uri': self.sip_from, + 'from_display_name': from_display_name, + 'to_uri': self.sip_to + } + + payload = { + 'message': { + 'token': self.token, + 'data': data, + 'android': { + 'priority': 'high', + 'ttl': '60s' + } + } + } + + return payload + diff --git a/pushserver/models/__init__.py b/pushserver/models/__init__.py new file mode 100644 index 0000000..b03d22f --- /dev/null +++ b/pushserver/models/__init__.py @@ -0,0 +1 @@ +__all__ = ['requests'] \ No newline at end of file diff --git a/pushserver/models/requests.py b/pushserver/models/requests.py new file mode 100644 index 0000000..5f01294 --- /dev/null +++ b/pushserver/models/requests.py @@ -0,0 +1,127 @@ +from pydantic import BaseModel, root_validator, validator + +from pushserver.resources import settings +from pushserver.resources.utils import fix_platform_name + + +def gen_validator_items() -> tuple: + """ + Generate some dicts according to minimum required parameters, + and each app required paramaters, usefull for request validation. + :return: two `dict` objects with common items and apps items. + """ + + common_items = {'app-id', 'call-id', + 'platform', 'from', 'token'} + + only_sylk_items = {'silent', 'to', 'event'} + only_linphone_items = set() + + apps_items = {'sylk': common_items | only_sylk_items, # union + 'linphone': common_items | only_linphone_items} + + return common_items, apps_items + + +common_items, apps_items = gen_validator_items() + + +def alias_rename(attribute: str) -> str: + """ + Rename request name attribute, replacing '_' by '_' + and removing 'sip_' characters. + :param attribute: `str` from request + :return: a `str` corresponding to alias. + """ + if attribute.startswith('sip_'): + return attribute.split('_', maxsplit=1)[1] + return attribute.replace('_', '-') + + +class WakeUpRequest(BaseModel): + # API expects a json object like: + app_id: str # id provided by the mobile application (bundle id) + platform: str # 'firebase', 'android', 'apple' or 'ios' + event: str = None # (required for sylk) 'incoming_session', 'incoming_conference' or 'cancel' + token: str # destination device token in hex + device_id: str = None # the device-id that owns the token (used for logging purposes) + call_id: str # (required for apple) unique sip parameter + sip_from: str # (required for firebase) SIP URI for who is calling + from_display_name: str = None # (required for sylk) display name of the caller + sip_to: str # SIP URI for who is called + media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' + silent: bool = True # True for silent notification + + class Config: + alias_generator = alias_rename + + @root_validator(pre=True) + def check_required_items_by_app(cls, values): + + app_id, platform = values.get('app-id'), values.get('platform') + + if not app_id: + raise ValueError(f"Field 'app-id' required") + if not platform: + raise ValueError(f"Field 'platform' required") + + platform = fix_platform_name(platform) + + if platform not in ('firebase', 'apple'): + raise ValueError(f"'{platform}' platform is not configured") + + pns_register = settings.params.pns_register + + if (app_id, platform) not in pns_register.keys(): + raise ValueError(f"{platform.capitalize()} {app_id} app " + f"is not configured") + + try: + name = pns_register[(app_id, platform)]['name'] + check_items = apps_items[name] + missing_items = [] + + for item in check_items: + if values.get(item) is None: + missing_items.append(item) + if missing_items: + missing_items_show = [] + for item in missing_items: + if item in ('sip_to', 'sip_from', 'device_id'): + item = item.split('_')[1] + else: + item = item.replace('-', '_') + missing_items_show.append(item) + + raise ValueError(f"'{' ,'.join(missing_items)}' " + f"item(s) missing.") + except KeyError: + pass + + event = values.get('event') + if event != 'cancel': + media_type = values.get('media-type') + if not media_type: + raise ValueError("Field media-type required") + if media_type not in ('audio', 'video', 'chat', 'sms', 'file-transfer'): + raise ValueError("media-type must be 'audio', 'video', " + "'chat', 'sms', 'file-transfer'") + if 'linphone' in name: + if event: + if event != 'incoming_session': + raise ValueError('event not found (must be incoming_sesion)') + else: + values['event'] = 'incoming_session' + return values + + @validator('platform') + def platform_valid_values(cls, v): + if v not in ('apple', 'ios', 'android', 'firebase'): + raise ValueError("platform must be 'apple', 'android' or 'firebase'") + return v + + @validator('event') + def event_valid_values(cls, v): + if v not in ('incoming_session', 'incoming_conference_request', 'cancel'): + raise ValueError("event must be 'incoming_session', 'incoming_conference_request' or 'cancel'") + return v diff --git a/pushserver/pns/__init__.py b/pushserver/pns/__init__.py new file mode 100644 index 0000000..7140d3b --- /dev/null +++ b/pushserver/pns/__init__.py @@ -0,0 +1 @@ +__all__ = ['apple', 'base', 'firebase', 'register'] \ No newline at end of file diff --git a/pushserver/pns/apple.py b/pushserver/pns/apple.py new file mode 100644 index 0000000..6572b7c --- /dev/null +++ b/pushserver/pns/apple.py @@ -0,0 +1,400 @@ +import json +import os +import socket +import ssl +import time + +import hyper +from hyper import HTTP20Connection, tls +from pushserver.models.requests import WakeUpRequest + +from pushserver.resources.utils import log_event, ssl_cert + +from pushserver.pns.base import PNS, PushRequest, PlatformRegister + + +class ApplePNS(PNS): + """ + An Apple Push Notification service + """ + + def __init__(self, app_id: str, app_name: str, url_push: str, + voip: bool, cert_file: str, key_file: str): + """ + :param app_id: `str`, blunde id provided by application. + :param url_push: `str`, URI to push a notification (from applications.ini) + :param cert_file `str`: path to APNS certificate (provided by dev app kit) + :param key_file `str`: path to APNS key (provided by dev app kit) + :param voip: `bool`, Required for apple, `True` for voip push notification type. + """ + self.app_id = app_id + self.app_name = app_name + self.url_push = url_push + self.voip = voip + self.key_file = key_file + self.cert_file = cert_file + + +class AppleConn(ApplePNS): + """ + An Apple connection + """ + + def __init__(self, app_id: str, app_name: str, url_push: str, + voip: bool, cert_file: str, key_file: str, + apple_pns: PNS, loggers: dict, port: int = 443): + """ + :param apple_pns `ApplePNS`: Apple Push Notification Service. + :param port `int`: 443 or 2197 to allow APNS traffic but block other HTTP traffic. + :param loggers: `dict` global logging instances to write messages (params.loggers) + :attribute ssl_context `ssl.SSLContext`: generated with a valid apple certificate. + :attribute connection `HTTP20Connection`: related to an app and its corresponding certificate. + """ + self.app_id = app_id + self.app_name = app_name + self.url_push = url_push + self.voip = voip + self.key_file = key_file + self.cert_file = cert_file + self.apple_pns = apple_pns + self.port = port + self.loggers = loggers + + + @property + def ssl_context(self) -> ssl.SSLContext: + """ + Define a ssl context using a cert_file to open a connection + requires a valid certificate file + + :return: a ssl.SSLContext object + """ + + cert_file = self.cert_file + key_file = self.key_file if self.key_file else self.cert_file + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + ssl_context.load_cert_chain(keyfile=key_file, certfile=cert_file) + + return ssl_context + + @property + def connection(self) -> HTTP20Connection: + """ + Open an apple connection + requires a ssl context + + :return: an hyper.http20.connection.HTTP20Connection object + """ + host = self.url_push + port = self.port + ssl_context = self.ssl_context + + connection = HTTP20Connection(host=host, port=port, + ssl_context=ssl_context, + force_proto=tls.H2C_PROTOCOL) + + cert_file_name = self.cert_file.split('/')[-1] + key_file_name = self.key_file.split('/')[-1] if self.key_file else None + + if self.loggers['debug']: + if key_file_name: + msg = f'{self.app_name.capitalize()} app: Connecting to {host}:{port} ' \ + f'using {cert_file_name} certificate ' \ + f'and {key_file_name} key files' + else: + msg = f'{self.app_name.capitalize()} app: Connecting to {host}:{port} ' \ + f'using {cert_file_name} certificate' + + log_event(loggers=self.loggers, msg=msg, level='deb') + + return connection + + +class AppleRegister(PlatformRegister): + def __init__(self, app_id: str, app_name:str, voip: bool, + credentials_path: str, config_dict: dict, loggers: dict): + + self.app_id = app_id + self.app_name = app_name + self.voip = voip + self.credentials_path = credentials_path + self.config_dict = config_dict + self.loggers = loggers + + self.error = '' + + @property + def url_push(self) -> str: + try: + return self.config_dict['apple_push_url'] + except KeyError: + self.error = 'apple_push_url not found in applications.ini' + return None + + @property + def certificate(self) -> dict: + if self.error: + return {} + else: + try: + cert_file = f"{self.credentials_path}/" \ + f"{self.config_dict['apple_certificate']}" + cert_exists = os.path.exists(cert_file) + if not cert_exists: + self.error = f"{cert_file} - no such file." + return {} + else: + return {'cert_file': cert_file, 'cert_exists': cert_exists} + except KeyError: + self.error = 'apple_certificate not found in applications.ini' + return {} + + @property + def key(self) -> dict: + if self.error: + return {} + try: + key_file = f"{self.credentials_path}/" \ + f"{self.config_dict['apple_key']}" + key_exists = os.path.exists(key_file) + if not key_exists: + self.error = f"{key_file} - no such file." + return {} + except KeyError: + return {} + return {'key_file': key_file, 'key_exists': key_exists} + + @property + def ssl_valid_cert(self) -> bool: + if self.error: + return + else: + try: + cert_file = self.certificate.get('cert_file') + key_file = self.key.get('key_file') + + if not (cert_file or key_file): + self.error = 'An apple certificate/key is needed to open a connection' + elif not ssl_cert(cert_file, key_file): + self.error = f"{cert_file} - bad ssl certificate." + return + else: + return True + except FileNotFoundError as exc: + self.error = exc + return + + @property + def apple_pns(self) -> ApplePNS: + if self.error: + return + + if self.ssl_valid_cert: + cert_file = self.certificate.get('cert_file') + key_file = self.key.get('key_file') + + return ApplePNS(app_id=self.app_id, + app_name=self.app_name, + url_push=self.url_push, + voip=self.voip, + cert_file=cert_file, + key_file=key_file) + + @property + def apple_conn(self): + if self.error: + return + return AppleConn(app_id=self.app_id, + app_name=self.app_name, + url_push=self.url_push, + voip=self.voip, + cert_file=self.certificate.get('cert_file'), + key_file=self.key.get('key_file'), + apple_pns=self.apple_pns, + loggers=self.loggers).connection + + @property + def register_entries(self): + if self.error: + return {} + return {'pns': self.apple_pns, + 'conn': self.apple_conn} + + +class ApplePushRequest(PushRequest): + """ + Apple push notification request + """ + + def __init__(self, error: str, app_name: str, app_id: str, + request_id: str, headers: str, payload: dict, + loggers: dict, log_remote: dict, + wp_request: WakeUpRequest, register: dict): + + """ + :param error: `str` + :param app_name: `str` 'linphone' or 'payload' + :param app_id: `str` bundle id + :param headers: `AppleHeaders` Apple push notification headers + :param payload: `ApplePayload` Apple push notification payload + :param wp_request: `WakeUpRequest` + :param loggers: `dict` global logging instances to write messages (params.loggers) + """ + self.error = error + self.app_name = app_name + self.app_id = app_id + self.platform = 'apple' + self.request_id = request_id + self.headers = headers + self.payload = payload + self.token = wp_request.token + self.call_id = wp_request.call_id + self.media_type = wp_request.media_type + self.wp_request = wp_request + self.loggers = loggers + self.log_remote = log_remote + + self.apple_pns = register['pns'] + self.connection = register['conn'] + self.path = f'/3/device/{self.token}' + + self.results = self.send_notification() + + def send_notification(self) -> dict: + """ + Send an apple push requests to a single device. + If status of response is like 5xx, + an exponential backoff factor is implemented + to retry the notification according to media type. + + :param `hstr` token: destination device. + :param `str` method: HTTP request method, must be 'POST'. + :param `AppleHeaders` headers: Apple push notification headers. + :param `ApplePayload` payload: Apple push notification payload. + """ + + if self.error: + self.log_error() + return {'code': 500, 'body': {}, + 'reason': 'Internal server error'} + + n_retries, backoff_factor = self.retries_params(self.media_type) + + log_path = f'http://{self.apple_pns.url_push}{self.path}' + + status_forcelist = tuple([status for status in range(500, 600)]) + + counter = 0 + + status = 500 + reason = '' + body = {} + + while counter <= n_retries: + if self.connection: + try: + self.log_request(path=log_path) + self.connection.request('POST', self.path, + self.payload, + self.headers) + + response = self.connection.get_response() + reason_str = response.read().decode('utf8').replace("'", '"') + + if reason_str: + reason_json = json.loads(reason_str) + reason = reason_json.get('reason') + else: + reason = reason_str + + status = response.status + + if status not in status_forcelist: + break + + except socket.gaierror: + reason = 'socket error' + + except hyper.http20.exceptions.StreamResetError: + reason = 'stream error' + + except ValueError as err: + reason = f'Bad type of object in headers or payload: {err}' + break + else: + reason = 'no connection' + + counter += 1 + timer = backoff_factor * (2 ** (counter - 1)) + time.sleep(timer) + + if counter == n_retries: + reason = 'max retries reached' + + url = f'https:{self.connection.host}:{self.connection.port}{self.path}' + + if status != 200: + details = self.apple_error_info(reason) + if details: + reason = f'{reason} - {details}' + + results = {'body': body, + 'code': status, + 'reason': reason, + 'url': url, + 'call_id': self.call_id, + 'token': self.token + } + + self.results = results + self.log_results() + return results + + def apple_error_info(self, reason): + """ + Give a human readable message according to 'reason' from apple APN. + :returns : a string with message according to reason + """ + + description_codes = {'ConnectionFailed': 'There was an error connecting to APNs.', + 'InternalException': 'This exception should not be raised. If it is, please report this as a bug.', + 'BadPayloadException': 'Something bad with the payload.', + 'BadCollapseId': 'The collapse identifier exceeds the maximum allowed size', + 'BadDeviceToken': 'The specified device token was bad. Verify that the request contains a valid token and that the token matches the environment.', + 'BadExpirationDate:': 'The apns-expiration value is bad.', + 'BadMessageId': 'The apns-id value is bad.', + 'BadPriority': 'The apns-priority value is bad.', + 'BadTopic': 'The apns-topic was invalid.', + 'DeviceTokenNotForTopic': 'The device token does not match the specified topic.', + 'DuplicateHeaders': 'One or more headers were repeated.', + 'IdleTimeout': 'Idle time out.', + 'MissingDeviceToken': 'The device token is not specified in the request :path. Verify that the :path header contains the device token.', + 'MissingTopic': 'The apns-topic header of the request was not specified and was required. The apns-topic header is mandatory when the client is connected using a certificate that supports multiple topics.', + 'PayloadEmpty': 'The message payload was empty.', + 'TopicDisallowed': 'Pushing to this topic is not allowed.', + 'BadCertificate': 'The certificate was bad.', + 'BadCertificateEnvironment': 'The client certificate was for the wrong environment.', + 'ExpiredProviderToken': 'The provider token is stale and a new token should be generated.', + 'Forbidden': 'The specified action is not allowed.', + 'InvalidProviderToken': 'The provider token is not valid or the token signature could not be verified.', + 'MissingProviderToken': 'No provider certificate was used to connect to APNs and Authorization header was missing or no provider token was specified.', + 'BadPath': 'The request contained a bad :path value.', + 'MethodNotAllowed': 'The specified :method was not POST.', + 'Unregistered': 'The device token is inactive for the specified topic.', + 'PayloadTooLarge': 'The message payload was too large. The maximum payload size is 4096 bytes.', + 'TooManyProviderTokenUpdates': 'The provider token is being updated too often.', + 'TooManyRequests': 'Too many requests were made consecutively to the same device token.', + 'InternalServerError': 'An internal server error occurred.', + 'ServiceUnavailable': 'The service is unavailable.', + 'Shutdown': 'The server is shutting down.', + 'InvalidPushType': 'The apns-push-type value is invalid.'} + try: + message = description_codes[reason] + return message + except KeyError: + return None + diff --git a/pushserver/pns/base.py b/pushserver/pns/base.py new file mode 100644 index 0000000..54d586c --- /dev/null +++ b/pushserver/pns/base.py @@ -0,0 +1,249 @@ +import concurrent +import datetime +import json +import socket + +import requests + +from pushserver.resources.utils import log_event + + +class PNS(object): + """ + Push Notification Service + """ + + def __init__(self, app_id: str, app_name: str, url_push: str, voip: bool = False): + """ + :param app_id: `str`, Id provided by application. + :param app_name: `str`, Application name. + :param url_push: `str`, URI to push a notification. + :param voip: `bool`, Required for apple, `True` for voip push notification type. + """ + self.app_id = app_id + self.app_name = app_name + self.url_push = url_push + self.voip = voip + + +class PlatformRegister(object): + def __init__(self, config_dict, credentials_path: str, loggers: dict): + + self.credentials_path = credentials_path + self.config_dict = config_dict + self.loggers = loggers + + +class PushRequest(object): + + def __init__(self, error: str, app_name: str, app_id: str, platform: str, + request_id: str, headers: str, payload: dict, token: str, + media_type: str, loggers: dict, log_remote: dict, wp_request: dict): + + self.error = error + self.app_name = app_name + self.app_id = app_id + self.platform = platform + self.request_id = request_id + self.headers = headers + self.payload = payload + self.token = token + self.media_type = media_type + self.loggers = loggers + self.log_remote = log_remote + self.wp_request = wp_request + + results = {} + + def retries_params(self, media_type: str) -> tuple: + if not media_type or media_type == 'sms': + n_tries = 11 + else: + n_tries = 7 + bo_factor = 0.5 + + return n_tries, bo_factor + + def log_request(self, path: str) -> None: + """ + Write in log information about push notification, + using log_event function + + :param path: `str`, path where push notification will be sent. + :param app_name: `str` for friendly log. + :param platform: `str`, 'apple' or 'firebase'. + :param request_id: `str`, request ID generated on request event. + :param headers: `json`, of push notification. + :param payload: `json`, of push notification. + :param loggers: `dict` global logging instances to write messages (params.loggers) + """ + + # log_app_name = app_name.capitalize() + log_platform = self.platform.capitalize() + + log_path = path if path else self.path + + level = 'info' + msg = f'outgoing {log_platform} request {self.request_id} to {log_path}' + log_event(loggers=self.loggers, msg=msg, level=level) + + if self.loggers['debug']: + level = 'deb' + msg = f'outgoing {log_platform} request {self.request_id} to {log_path}' + log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) + + msg = f'outgoing {log_platform} request {self.request_id} headers: {self.headers}' + log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) + + msg = f'outgoing {log_platform} request {self.request_id} body: {self.payload}' + log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) + + def log_error(self): + level = 'error' + msg = f"outgoing {self.platform.title()} response for " \ + f"{self.request_id}, push failed: " \ + f"{self.error}" + log_event(loggers=self.loggers, msg=msg, level=level) + + def server_ip(self, destination): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect((destination, 1)) + return s.getsockname()[0] + except socket.error: + return None + + def log_remotely(self, body: dict, code: str, reason: str, url: str) -> None: + """ + Fork a log of a payload incoming request to a remote url + :param body: `dict` response to push request + :param code: `int` of response to push request + :param reason: `str` of response to push request + """ + + push_response = {'code': code, 'description': reason, 'push_url': url} + headers = {'Content-Type': 'application/json'} + server_ip = self.server_ip('1.2.3.4') + now = datetime.datetime.now() + timestamp = now.strftime("%Y-%m-%d %H:%M:%S") + payload = {'request': body, 'response': push_response, + 'server_ip': server_ip,'timestamp': timestamp} + + task = 'log remote' + + log_key = self.log_remote.get('log_key') + log_time_out = self.log_remote.get('log_time_out') + + results = [] + + for log_url in self.log_remote['log_urls']: + msg = f'{task} request {self.request_id} to {log_url}' + log_event(loggers=self.loggers, msg=msg, level='info') + + if self.loggers['debug']: + msg = f'{task} request {self.request_id} to {log_url} headers: {headers}' + log_event(loggers=self.loggers, msg=msg, level='info', to_file=True) + msg = f'{task} request {self.request_id} to {log_url} body: {payload}' + log_event(loggers=self.loggers, msg=msg, level='info', to_file=True) + + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + futures = [ + executor.submit( + lambda: requests.post(url=log_url, + json=payload, + headers=headers, + timeout=log_time_out or 2) + ) + for log_url in self.log_remote['log_urls'] + ] + + results = [ + f.result() + for f in futures + ] + except requests.exceptions.ConnectionError as exc: + msg = f'{task} for {self.request_id}: connection error {exc}' + log_event(loggers=self.loggers, msg=msg, level='error') + log_event(loggers=self.loggers, msg=msg, level='error', to_file=True) + except requests.exceptions.ReadTimeout as exc: + msg = f'{task} for {self.request_id}: connection error {exc}' + log_event(loggers=self.loggers, msg=msg, level='error') + log_event(loggers=self.loggers, msg=msg, level='error', to_file=True) + + if not results: + return + + for url, result in list(zip(self.log_remote['log_urls'], results)): + code = result.status_code + text = result.text[:500] + + if log_key: + try: + result = result.json() + value = result.get(log_key) + except (json.decoder.JSONDecodeError, AttributeError): + value = {} + + if value: + msg = f'{task} response for request {self.request_id} from {url} - ' \ + f'{code} {log_key}: {value}' + log_event(loggers=self.loggers, msg=msg, level='info') + + if self.loggers['debug']: + log_event(loggers=self.loggers, msg=msg, level='info', to_file=True) + + else: + msg = f'{task} response for request {self.request_id} - ' \ + f'code: {code}, key not found' + log_event(loggers=self.loggers, msg=msg, level='error') + + if self.loggers['debug']: + msg = f'{task} response for request {self.request_id} - ' \ + f'{log_key} key not found in: {text}' + log_event(loggers=self.loggers, msg=msg, level='error', to_file=True) + + else: + msg = f'{task} code response for request {self.request_id} ' \ + f'from {url}: {code}' + log_event(loggers=self.loggers, msg=msg, level='info') + + if self.loggers['debug']: + msg = f'{task} response for request {self.request_id} ' \ + f'from {url}: {code} {text}' + log_event(loggers=self.loggers, msg=msg, level='info', to_file=True) + + def log_results(self): + """ + Log to journal system the result of push notification + """ + body = self.results['body'] + code = self.results['code'] + reason = self.results['reason'] + url = self.results['url'] + + if self.loggers['debug']: + level = 'info' + body = json.dumps(body) + msg = f"outgoing {self.platform.title()} response for request " \ + f"{self.request_id} body: {body}" + log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) + + if code == 200: + level = 'info' + msg = f"outgoing {self.platform.title()} response for request " \ + f"{self.request_id}: push notification sent successfully" + log_event(loggers=self.loggers, msg=msg, level=level) + else: + level = 'error' + msg = f"outgoing {self.platform.title()} response for " \ + f"{self.request_id}, push failed with code {code}: {reason}" + log_event(loggers=self.loggers, msg=msg, level=level) + + body = {'incoming_body': self.wp_request.__dict__, + 'outgoing_headers': self.headers, + 'outgoing_body': self.payload + } + + if self.log_remote.get('log_urls'): + self.log_remotely(body=body, code=code, reason=reason, url=url) diff --git a/pushserver/pns/firebase.py b/pushserver/pns/firebase.py new file mode 100644 index 0000000..c402557 --- /dev/null +++ b/pushserver/pns/firebase.py @@ -0,0 +1,321 @@ +import json +import os +import time + +import oauth2client +import requests +from oauth2client.service_account import ServiceAccountCredentials + +from pushserver.models.requests import WakeUpRequest + +from requests.adapters import HTTPAdapter +from urllib3 import Retry + +from pushserver.pns.base import PNS, PushRequest, PlatformRegister +from pushserver.resources.utils import log_event, fix_non_serializable_types + + +class FirebasePNS(PNS): + """ + A Firebase Push Notification service + """ + + def __init__(self, app_id: str, app_name: str, url_push: str, + voip: bool, auth_key: str = None, auth_file: str = None): + """ + :param app_id `str`: Application ID. + :param url_push `str`: URI to push a notification. + :param voip `bool`: required for apple, `True` for voip push notification type. + :param auth_key `str`: A Firebase credential for push notifications. + :param auth_file `str`: A Firebase credential for push notifications. + """ + self.app_id = app_id + self.app_name = app_name + self.url_push = url_push + self.voip = voip + self.auth_key = auth_key + self.auth_file = auth_file + self.error = '' + self.access_token = self.set_access_token() + + def set_access_token(self) -> str: + """ + Retrieve a valid access token that can be used to authorize requests. + :return: `str` Access token. + """ + + if not self.auth_file: + self.error = f"Cannot generated Firebase access token, " \ + f"no auth file provided" + return '' + + if not os.path.exists(self.auth_file): + self.error = f"Cannot generated Firebase access token, " \ + f"auth file {self.auth_file} not found" + return '' + + scopes = ['https://www.googleapis.com/auth/firebase.messaging'] + try: + credentials = ServiceAccountCredentials.from_json_keyfile_name(self.auth_file, + scopes) + oauth2client.client.logger.setLevel('CRITICAL') + access_token_info = credentials.get_access_token() + + return access_token_info.access_token + except Exception as e: + self.error = f"Error: cannot generated Firebase access token: {e}" + return '' + + +class FirebaseRegister(PlatformRegister): + def __init__(self, app_id: str, app_name: str, voip: bool, + config_dict: dict, credentials_path: str, loggers: dict): + + self.app_id = app_id + self.app_name = app_name + self.voip = voip + + self.credentials_path = credentials_path + self.config_dict = config_dict + self.loggers = loggers + + self.error = '' + self.auth_key, self.auth_file = self.set_auths() + + @property + def url_push(self): + try: + return self.config_dict['firebase_push_url'] + except KeyError: + self.error = 'firebase_push_url not found in applications.ini' + return None + + def set_auths(self): + + auth_key = None + auth_file = None + try: + auth_key = self.config_dict['firebase_authorization_key'] + except KeyError: + try: + auth_file = self.config_dict['firebase_authorization_file'] + if self.credentials_path: + auth_file = f"{self.credentials_path}/" \ + f"{auth_file}" + else: + pass + if not os.path.exists(auth_file): + self.error = f'{auth_file} - no such file' + except KeyError: + self.error = 'not firebase_authorization_key or ' \ + 'firebase_authorization_file found in applications.ini' + + return auth_key, auth_file + + @property + def pns(self) -> FirebasePNS: + pns = None + if self.auth_key: + auth_file = '' + pns = FirebasePNS(app_id=self.app_id, + app_name=self.app_name, + url_push=self.url_push, + voip=self.voip, + auth_key=self.auth_key, + auth_file=auth_file) + elif self.auth_file: + pns = FirebasePNS(app_id=self.app_id, + app_name=self.app_name, + url_push=self.url_push, + voip=self.voip, + auth_file=self.auth_file) + + self.error = pns.error if pns.error else '' + return pns + + @property + def register_entries(self): + if self.error: + return {} + + return {'pns': self.pns, + 'access_token': self.pns.access_token, + 'auth_key': self.auth_key, + 'auth_file': self.auth_file} + + +class FirebasePushRequest(PushRequest): + """ + Firebase push notification request + """ + + def __init__(self, error: str, app_name: str, app_id: str, + request_id: str, headers: str, payload: dict, + loggers: dict, log_remote: dict, + wp_request: WakeUpRequest, register: dict): + + """ + :param error: `str` + :param app_name: `str` 'linphone' or 'payload' + :param app_id: `str` bundle id + :param headers: `AppleHeaders` Apple push notification headers + :param payload: `ApplePayload` Apple push notification payload + :param wp_request: `WakeUpRequest` + :param loggers: `dict` global logging instances to write messages (params.loggers) + """ + self.error = error + self.app_name = app_name + self.app_id = app_id + self.platform = 'firebase' + self.request_id = request_id + self.headers = headers + self.payload = payload + self.token = wp_request.token + self.wp_request = wp_request + self.loggers = loggers + self.log_remote = log_remote + + self.pns = register['pns'] + self.path = self.pns.url_push + self.results = self.send_notification() + + def requests_retry_session(self, counter=0): + """ + Define parameters to retry a push notification + according to media_type. + :param counter: `int` (optional) if retries was necessary + because of connection fails + + Following rfc3261 specification, an exponential backoff factor is used. + More specifically: + backoff = 0.5 + T1 = 500ms + max_retries_call = 7 + time_to_live_call = 64 seconds + max_retries_sms = 11 + time_to_live_sms ~ 2 hours + """ + + retries = self.retries_params[self.media_type] - counter + backoff_factor = self.retries_params['bo_factor'] * 0.5 * 2 ** counter + + status_forcelist = tuple([status for status in range(500, 600)]) + session = None + + session = session or requests.Session() + retry = Retry( + total=retries, + read=retries, + connect=retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist) + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + def send_notification(self) -> dict: + """ + Send a Firebase push notification + """ + + if self.error: + self.log_error() + return {'code': 500, 'body': {}, 'reason': 'Internal server error'} + + n_retries, backoff_factor = self.retries_params(self.wp_request.media_type) + + counter = 0 + error = False + code = 500 + reason = "" + body = None + response = None + + while counter <= n_retries: + + self.log_request(path=self.pns.url_push) + + try: + + response = requests.post(self.pns.url_push, + self.payload, + headers=self.headers) + break + except requests.exceptions.RequestException as e: + error = True + reason = f'connection failed: {e}' + counter += 1 + timer = backoff_factor * (2 ** (counter - 1)) + time.sleep(timer) + + if counter == n_retries: + reason = "maximum retries reached" + + elif error: + try: + response = self.requests_retry_session(counter). \ + post(self.pns.url_push, + self.payload, + headers=self.headers) + except Exception as x: + level = 'error' + msg = f"outgoing {self.platform.title()} response for " \ + f"{self.request_id}, push failed: " \ + f"an error occurred in {x.__class__.__name__}" + log_event(loggers=self.loggers, msg=msg, level=level) + try: + body = response.__dict__ + except (TypeError, ValueError): + code = 500 + reason = 'cannot parse response body' + body = {} + else: + reason = body.get('reason') + code = response.status_code + + for k in ('raw', 'request', 'connection', 'cookies', 'elapsed'): + try: + del body[k] + except KeyError: + pass + except TypeError: + break + + body = json.dumps(fix_non_serializable_types(body)) + + if isinstance(body, str): + body = json.loads(body) + + keys = list(body.keys()) + for key in keys: + if not body[key]: + del body[key] + + results = {'body': body, + 'code': code, + 'reason': reason, + 'url': self.pns.url_push, + 'call_id': self.wp_request.call_id, + 'token': self.token + } + + self.results = results + self.log_results() + + # Request is missing required authentication credential. + # Expected OAuth 2 access token, login cookie or other valid authentication + # credential. UNAUTHENTICATED + if code == 401 and reason == 'Unauthorized': + if not self.pns.get('refreshed_token'): + level = 'warn' + msg = f"outgoing {self.platform.title()} response for request " \ + f"{self.request_id} need a new access token - " \ + f"server will refresh it and try again" + log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) + # retry with a new Fireplace access token + self.pns['refreshed_token'] = True + self.pns.access_token = self.pns.set_access_token() + self.results = self.send_notification() + + return results diff --git a/pushserver/pns/register.py b/pushserver/pns/register.py new file mode 100644 index 0000000..b8c46b6 --- /dev/null +++ b/pushserver/pns/register.py @@ -0,0 +1,217 @@ +import configparser +import importlib +import os +import sys + +from pushserver.resources.utils import log_event + + +def check_apps_classes(name: str, platform: str, extra_dir: str) -> tuple: + """ + Check for custom classes + :param name: `str` name of custom app, which corresponds to module + :param platform: `str` 'apple' or 'firebase' + :param extra_dir: `str` path to extra applications dir + :return: a tuple with, error (str), headers_class (class), payload_class (class) + """ + + # Check for known apps: + try: + module = importlib.import_module(f'pushserver.applications.{name.lower()}') + headers_class_name = f'{platform.capitalize()}{name.lower().capitalize()}Headers' + headers_class = getattr(module, headers_class_name) + + payload_class_name = f'{platform.capitalize()}{name.lower().capitalize()}Payload' + payload_class = getattr(module, payload_class_name) + except ModuleNotFoundError: + headers_class, payload_class = None, None + + if headers_class and payload_class: + return '', headers_class, payload_class + + if not extra_dir: + if os.path.isdir('/etc/sylk-pushserver'): + extra_dir = '/etc/sylk-pushserver/applications' + else: + current_dir = os.getcwd() + extra_dir = current_dir + "/config/applications" + + if os.path.isdir(extra_dir): + sys.path.append(extra_dir) + + error = '' + + try: + module = importlib.import_module(name.lower()) + try: + headers_class_name = f'{platform.capitalize()}{name.lower().capitalize()}Headers' + headers_class = getattr(module, headers_class_name) + try: + payload_class_name = f'{platform.capitalize()}{name.lower().capitalize()}Payload' + payload_class = getattr(module, payload_class_name) + except AttributeError: + error = f'{platform.capitalize()}{name.lower().capitalize()}Payload class not found ' \ + f'in {name.lower()}' + headers_class, payload_class = None, None + except AttributeError: + error = f'{platform.capitalize()}{name.lower().capitalize()}Headers class not found ' \ + f'in {name.lower()}' + headers_class, payload_class = None, None + except ModuleNotFoundError: + error = f'{name.lower()} module not found' + headers_class, payload_class = None, None + + return error, headers_class, payload_class + + +def check_pns_classes(platform: str, extra_dir: str) -> tuple: + """ + Check for custom classes + :param name: `str` name of custom app, which corresponds to module + :param platform: `str` 'apple' or 'firebase' + :param extra_dir: `str` path to extra applications dir + :return: a tuple with, error (str), headers_class (class), payload_class (class) + """ + + # Check for known apps: + try: + register_module = importlib.import_module(f'pushserver.pns.{platform}') + register_class = getattr(register_module, f'{platform.capitalize()}Register') + pns_class = getattr(register_module, f'{platform.capitalize()}PNS') + except ModuleNotFoundError: + register_class = None + + if register_class: + return '', register_class + + if not extra_dir: + if os.path.isdir('/etc/sylk-pushserver'): + extra_dir = '/etc/sylk-pushserver/pns' + else: + current_dir = os.getcwd() + extra_dir = current_dir + "/config/pns" + + if os.path.isdir(extra_dir): + sys.path.append(extra_dir) + + error = '' + + try: + register_module = importlib.import_module(platform.lower()) + try: + register_class = getattr(register_module, f'{platform.capitalize()}Register') + pns_class = getattr(register_module, f'{platform.capitalize()}PNS') + except AttributeError: + error = f'{platform.capitalize()}PNS class not found ' \ + f'in {platform.lower()}' + register_class = None + except ModuleNotFoundError: + error = f'{platform.lower()} module not found in pushserver/pns or {extra_dir}' + register_class = None + + return error, register_class + + +def get_pns_from_config(config_path: str, credentials: str, apps_extra_dir: str, + pns_extra_dir: str, loggers: dict) -> dict: + """ + Create a dictionary with applications with their own PN server address, certificates and keys + :param config_path: `str` path to config file (see config.ini.example) + :param credentials: `str` path to credentials dir + :param apps_extra_dir: `str` path to extra applications dir + :param pns_extra_dir: `str` path to extra pns dir + :param loggers: `dict` global logging instances to write messages (params.loggers) + """ + config = configparser.ConfigParser() + config.read(config_path) + # pns_dict = {(, 'apple')): {'id': str, + # 'name': str, + # 'headers_class': headers_class, + # 'payload_class': payload_class, + # 'pns': ApplePNS, + # 'conn': AppleConn} + # (, 'firebase'): {'id': str, + # 'name': str, + # 'headers_class': headers_class, + # 'payload_class': payload_class, + # 'pns': FirebasePNS} + # ( ... + # } + + pns_register = {} + invalid_apps = {} + for id in config.sections(): + app_id = config[id]['app_id'] + name = config[id]['app_type'].lower() + platform = config[id]['app_platform'].lower() + voip = config[id].get('voip') + error, log, log_urls, log_key, log_timeout = '', False, '', '', None + try: + log_urls_str = config[id]['log_remote_urls'] + log_urls = set(log_urls_str.split(',')) + log_key = config[id].get('log_key') + log_timeout = config[id].get('log_time_out') + log_timeout = int(log_timeout) if log_timeout else None + except KeyError: + log = False + except SyntaxError: + error = f'log_remote_urls = {log_urls_str} - bad syntax' + log = False + log_remote = {'error': error, + 'log_urls': log_urls, + 'log_remote_key': log_key, + 'log_remote_timeout': log_timeout} + + if voip: + voip = True if voip.lower() == 'true' else False + + error, register_class = check_pns_classes(platform=platform, extra_dir=pns_extra_dir) + + if error: + reason = error + invalid_apps[(app_id, platform)] = {'name': name, 'reason': reason} + continue + + register = register_class(app_id=app_id, + app_name=name, + voip=voip, + config_dict=config[id], + credentials_path=credentials, + loggers=loggers) + register_entries = register.register_entries + error = register.error + + if error: + reason = error + invalid_apps[(app_id, platform)] = {'name': name, 'reason': reason} + continue + + error, \ + headers_class, \ + payload_class = check_apps_classes(name, + platform, + apps_extra_dir) + + if error: + reason = error + invalid_apps[(app_id, platform)] = {'name': name, + 'reason': reason} + continue + + pns_register[(app_id, platform)] = {'id': id, + 'name': name, + 'headers_class': headers_class, + 'payload_class': payload_class, + 'log_remote': log_remote} + + for k, v in register_entries.items(): + pns_register[(app_id, platform)][k] = v + + pnses = [] + for app in pns_register.keys(): + pnses.append(pns_register[app]['pns'].__class__.__name__) + pnses = set(pnses) + + return {'pns_register': pns_register, + 'invalid_apps': invalid_apps, + 'pnses': pnses} diff --git a/pushserver/resources/__init__.py b/pushserver/resources/__init__.py new file mode 100644 index 0000000..95bf8af --- /dev/null +++ b/pushserver/resources/__init__.py @@ -0,0 +1 @@ +__all__ = ['notification', 'pns', 'settings', 'utils'] diff --git a/pushserver/resources/notification.py b/pushserver/resources/notification.py new file mode 100644 index 0000000..cb56c30 --- /dev/null +++ b/pushserver/resources/notification.py @@ -0,0 +1,94 @@ +import importlib +import json + +from pushserver.models.requests import WakeUpRequest +from pushserver.resources import settings + + +def handle_request(wp_request, request_id: str) -> dict: + """ + Create a PushNotification object, + and call methods to send the notification. + + :param wp_request: `WakeUpRequest', received from /push route. + :param loggers: `dict` global logging instances to write messages (params.loggers) + :param request_id: `str`, request ID generated on request event. + :return: a `dict` with push notification results + """ + push_notification = PushNotification(wp_request=wp_request, + request_id=request_id) + results = push_notification.send_notification() + return results + + +class PushNotification(object): + """ + Push Notification actions from wake up request + """ + + def __init__(self, wp_request: WakeUpRequest, request_id: str): + """ + :param wp_request: `WakeUpRequest`, from http request + :param request_id: `str`, request ID generated on request event. + """ + self.wp_request = wp_request + self.app_id = self.wp_request.app_id + self.platform = self.wp_request.platform + self.pns_register = settings.params.pns_register + self.request_id = request_id + self.loggers = settings.params.loggers + self.log_remote = self.pns_register[(self.app_id, self.platform)].get('log_remote') + self.config_dict = self.pns_register[(self.app_id, self.platform)] + + self.app_name = self.pns_register[(self.app_id, self.platform)]['name'] + + self.args = [self.app_id, self.wp_request.event, self.wp_request.token, + self.wp_request.call_id, self.wp_request.sip_from, + self.wp_request.from_display_name, self.wp_request.sip_to, + self.wp_request.media_type, self.wp_request.silent] + + @property + def custom_apps(self): + apps = [self.pns_register[key]['name'] for key in self.pns_register.keys()] + custom_apps = set(app for app in apps if app not in ('sylk', 'linphone')) + return custom_apps + + def send_notification(self) -> dict: + """ + Send a push notification according to wakeup request params. + """ + error = '' + headers_class = self.pns_register[(self.app_id, self.platform)]['headers_class'] + headers = headers_class(*self.args).headers + + payload_class = self.pns_register[(self.app_id, self.platform)]['payload_class'] + payload = payload_class(*self.args).payload + payload = json.dumps(payload) + + if not (headers and payload): + error = f'{headers_class.__name__} and {payload_class.__name__} ' \ + f'returned bad objects:' \ + f'{headers}, {payload}' + + if not isinstance(headers, dict) or not isinstance(payload, str): + error = f'{headers_class.__name__} and {payload_class.__name__} ' \ + f'returned bad objects:' \ + f'{headers}, {payload}' + + register = self.pns_register[(self.app_id, self.platform)] + platform_module = importlib.import_module(f'pushserver.pns.{self.platform}') + + push_request_class = getattr(platform_module, + f'{self.platform.capitalize()}PushRequest') + + push_request = push_request_class(error=error, + app_name=self.app_name, + app_id=self.app_id, + request_id=self.request_id, + headers=headers, + payload=payload, + loggers=self.loggers, + log_remote=self.log_remote, + wp_request=self.wp_request, + register=register) + return push_request.results diff --git a/pushserver/resources/server.py b/pushserver/resources/server.py new file mode 100644 index 0000000..50fb87d --- /dev/null +++ b/pushserver/resources/server.py @@ -0,0 +1,148 @@ +import asyncio +import os + +from typing import Callable + +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from pushserver.api.errors.validation_error import validation_exception_handler +from pushserver.api.routes.api import router +from pushserver.resources import settings +from pushserver.resources.utils import log_event + + +def get_server() -> FastAPI: + server = FastAPI(title='sylk-pushserver', version='1.0.0', debug=True) + server.add_event_handler("startup", create_start_server_handler()) + server.add_exception_handler(RequestValidationError, validation_exception_handler) + server.include_router(router) + + return server + + +async def autoreload_read_config(wait_for: float = 0.1) -> None: + """ + Set global parameters when config folder changes. + :param wait_for: `float` time to sleep between looks for changes. + """ + # thanks to lbellomo for the concept of this function + + to_watch = {} + paths_list = [settings.params.file['path'], + settings.params.apps['path'], + settings.params.apps['credentials']] + for path in paths_list: + try: + to_watch[path] = os.stat(path).st_mtime + except FileNotFoundError: + pass + + while True: + for path in to_watch.keys(): + last_st_mtime = to_watch[path] + path_modified = last_st_mtime != os.stat(path).st_mtime + if path_modified: + to_watch[path] = os.stat(path).st_mtime + settings.params = settings.update_params(settings.params.config_dir, + settings.params.debug, + settings.params.ip, + settings.params.port) + await asyncio.sleep(wait_for) + break + await asyncio.sleep(wait_for) + + +def create_start_server_handler() -> Callable: # type: ignore + wait_for = 0.1 + + async def start_server() -> None: + + asyncio.create_task(autoreload_read_config(wait_for=wait_for)) + + level = 'info' + loggers = settings.params.loggers + register = settings.params.register + + pns_register = register['pns_register'] + msg = f"Loaded {len(pns_register)} applications from " \ + f"{settings.params.apps['path']}:" + log_event(loggers=loggers, msg=msg, level=level) + + for app in pns_register.keys(): + app_id, platform = app + name = pns_register[app]['name'] + msg = f"Loaded {platform.capitalize()} "\ + f"{name.capitalize()} app {app_id}" \ + + log_event(loggers=loggers, msg=msg, level=level) + + if settings.params.loggers['debug']: + headers_class = pns_register[app]['headers_class'] + payload_class = pns_register[app]['payload_class'] + + msg = f"{name.capitalize()} app {app_id} classes: " \ + f"{headers_class.__name__}, {payload_class.__name__}" + log_event(loggers=loggers, msg=msg, level='deb') + + log_remote = pns_register[app]['log_remote'] + if log_remote['error']: + msg = f"{name.capitalize()} loading of log remote settings failed: " \ + f"{log_remote['error']}" + log_event(loggers=loggers, msg=msg, level='deb') + elif log_remote.get('log_remote_urls'): + log_settings = '' + for k, v in log_remote.items(): + if k == 'error': + continue + if k == 'log_urls': + v = ', '.join(v) + if k == 'log_remote_key' and not v: + continue + if k == 'log_remote_timeout' and not v: + continue + log_settings += f'{k}: {v} ' + msg = f'{name.capitalize()} log remote settings: {log_settings}' + log_event(loggers=loggers, msg=msg, level='deb') + + invalid_apps = register['invalid_apps'] + for app in invalid_apps.keys(): + app_id, platform = app[0], app[1] + name = invalid_apps[app]['name'] + reason = invalid_apps[app]['reason'] + msg = f"{name.capitalize()} app with {app_id} id for {platform} platform " \ + f"will not be available, reason: {reason}" + log_event(loggers=loggers, msg=msg, level=level) + + pnses = register['pnses'] + + if settings.params.loggers['debug']: + level = 'deb' + msg = f'Loaded {len(pnses)} Push notification services: ' \ + f'{", ".join(pnses)}' + log_event(loggers=loggers, msg=msg, level=level) + + for pns in pnses: + msg = f"{pns.split('PNS')[0]} Push Notification Service - " \ + f"{pns} class" + log_event(loggers=loggers, msg=msg, level=level) + + if settings.params.allowed_pool: + nets = [net.with_prefixlen for net in settings.params.allowed_pool] + msg = f"Allowed hosts: " \ + f"{', '.join(nets)}" + log_event(loggers=loggers, msg=msg, level=level) + + if settings.params.loggers['debug']: + msg = 'Server is now ready to answer requests' + log_event(loggers=loggers, msg=msg, level='deb') + + ip, port = settings.params.server['host'], settings.params.server['port'] + msg = f'Sylk Pushserver listening on http://{ip}:{port}' + log_event(loggers=loggers, msg=msg, level='info') + + await asyncio.sleep(wait_for) + + return start_server + + +server = get_server() diff --git a/pushserver/resources/settings.py b/pushserver/resources/settings.py new file mode 100644 index 0000000..d416f3b --- /dev/null +++ b/pushserver/resources/settings.py @@ -0,0 +1,344 @@ +import configparser +import logging +import os +from ipaddress import ip_network +from pushserver.pns.register import get_pns_from_config + + +try: + from systemd.journal import JournaldLogHandler +except ImportError: + from systemd.journal import JournalHandler + + +class ConfigParams(object): + """ + Settings params to share across modules. + + :param dir: `dict` with 'path' to config dir an 'error' if exists + :param file: `dict` with 'path' to config file + :param server: `dict` with host, port and tls_cert from config file + :param apps: `dict` with path, credentials and extra_dir from config file + :param loggers: `dict` global logging instances to write messages (params.loggers) + :param allowed_pool: `list` of allowed hosts for requests + + if there is any error with config dir or config file, + others params will be setted to None. + """ + + def __init__(self, config_dir, debug, ip, port): + + self.default_dir = '/etc/sylk-pushserver' + self.current_dir = os.getcwd() + self.default_host, self.default_port = '127.0.0.1', '8400' + + self.config_dir = config_dir + self.debug = debug + self.ip, self.port = ip, port + + self.dir = self.set_dir() + self.file = self.set_file() + self.loggers = self.set_loggers() + self.apps = self.set_apps() + self.register = self.set_register() + self.allowed_pool = self.set_allowed_pool() + self.return_async = self.set_return_async() + + def set_dir(self): + """ + if config directory was not specified from command line + look for general.ini in /etc/sylk-pushserver or ./config + if general.ini is not there, server will start with default settings + """ + dir, error = {}, '' + + config_dir = self.config_dir + + if not config_dir: + error = f'{self.config_dir} no such directory, ' \ + f'server will run with default settings.' + if os.path.exists(f'{self.default_dir}/general.ini'): + config_dir = self.default_dir + elif os.path.exists(f'{self.current_dir}/config/general.ini'): + config_dir = self.current_dir + else: + error = f'general.ini cofig files not found in ' \ + f'{self.default_dir} ' \ + f'or {self.current_dir}/config,' \ + f'server will run with default settings.' + + dir['path'], dir['error'] = config_dir, error + return dir + + def set_file(self): + + file, path, error = {}, '', '' + if not self.dir.get('error'): + path = f"{self.dir['path']}/general.ini" + error = '' + elif 'default' in self.dir.get('error'): + path = '' + error = self.dir.get('error') + + file['path'], file['error'] = path, error + return file + + @property + def server(self): + server = {} + + if not self.file.get('error') or 'default' in self.file.get('error'): + config = configparser.ConfigParser() + config.read(self.file['path']) + try: + server_settings = config['server'] + except KeyError: + server_settings = {} + if self.ip: + server['host'] = self.ip + else: + server['host'] = server_settings.get('host') or self.default_host + if self.port: + server['port'] = self.port + else: + server['port'] = server_settings.get('port') or self.default_port + + server['tls_cert'] = server_settings.get('tls_certificate') or '' + return server + + def set_apps(self): + + apps = {} + apps_path, apps_cred, apps_extra_dir, pns_extra_dir = '', '', '', '' + apps_settings = False + + if not self.dir['error']: + if self.file['path']: + config = configparser.ConfigParser() + config.read(self.file['path']) + try: + apps_path = f"{config['applications']['config_file']}" + apps_cred = f"{config['applications'].get('credentials_folder')}" + apps_extra_dir = f"{config['applications'].get('extra_applications_dir')}" + pns_extra_dir = f"{config['applications'].get('extra_pns_dir')}" + paths_list = [apps_path, apps_cred, apps_extra_dir, pns_extra_dir] + + for i, path in enumerate(paths_list): + if not path.startswith('/'): + paths_list[i] = f'{self.config_dir}/{path}' + + apps_path = paths_list[0] + apps_cred = paths_list[1] + apps_extra_dir = paths_list[2] + pns_extra_dir = paths_list[3] + + apps_path_exists = os.path.exists(apps_path) + cred_path_exists = os.path.exists(apps_cred) + extra_apps_dir_exists = os.path.exists(apps_extra_dir) + extra_pns_dir_exists = os.path.exists(pns_extra_dir) + + if not apps_path_exists: + self.dir['error'] = f" Can not start: " \ + f"applications.ini config file not found in " \ + f"{apps_path}" + apps_path, apps_cred, apps_extra_dir, pns_extra_dir = '', '', '', '' + if not cred_path_exists: + self.dir['error'] = f" Can not start: " \ + f"{apps_cred} no such directory" + apps_path, apps_cred, apps_extra_dir , pns_extra_dir = '', '', '', '' + if apps_path_exists and cred_path_exists: + apps_settings = True + + except KeyError: + apps_path, apps_cred, apps_extra_dir = '', '', '' + + elif os.path.exists(self.default_dir): + if os.path.exists(f'{self.default_dir}/applications.ini'): + apps_path = f'{self.default_dir}/applications.ini' + apps_cred = f'{self.default_dir}/credentials' + apps_extra_dir = f'{self.default_dir}/applications' + pns_extra_dir = f'{self.default_dir}/pns' + apps_settings = True + + if not apps_settings: + if os.path.exists(self.default_dir): + if os.path.exists(f'{self.default_dir}/applications.ini'): + apps_path = f'{self.default_dir}/applications.ini' + apps_cred = f'{self.default_dir}/credentials' + apps_extra_dir = f'{self.default_dir}/applications' + pns_extra_dir = f'{self.default_dir}/pns' + apps_settings = True + + if apps_path: + config = configparser.ConfigParser() + config.read(apps_path) + if config.sections(): + for id in config.sections(): + try: + config[id]['app_id'] + config[id]['app_type'] + config[id]['app_platform'].lower() + except KeyError: + self.dir['error'] = f'Can not start: ' \ + f'{apps_path} config file has not ' \ + f'valid application settings' + apps_path, apps_cred, apps_extra_dir = '', '', '' + + if not apps_settings: + self.dir['error'] = f'Can not start: ' \ + f'applications.ini config file not found' + + apps['path'] = apps_path + apps['credentials'] = apps_cred + apps['apps_extra_dir'] = apps_extra_dir + apps['pns_extra_dir'] = pns_extra_dir + return apps + + def set_loggers(self): + + debug = self.debug if self.debug else False + loggers = {} + config = configparser.ConfigParser() + default_path = '/var/log/sylk-pushserver/push.log' + log_path = '' + + if not self.file['error']: + config.read(self.file['path']) + + try: + log_path = f"{config['server']['log_file']}" + try: + str_debug = config['server']['debug'].lower() + except KeyError: + str_debug = False + debug = True if str_debug == 'true' else False + debug = debug or self.debug + except KeyError: + log_path, debug = default_path, False + + logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s') + + formatter = logging.Formatter('[%(levelname)s] %(message)s') + + # log to file + if log_path: + logger_file = logging.getLogger('to_file') + logger_file.setLevel(logging.DEBUG) + try: + loggers['to_file'] = logger_file + hdlr = logging.FileHandler(log_path) + hdlr.setFormatter(formatter) + logger_file.addHandler(hdlr) + except PermissionError: + self.dir['error'] = f'Permission denied: {log_path}, ' \ + f'debug log file requires ' \ + f'run sylk-pushserver with sudo.' + # log to journal + logger_journal = logging.getLogger('to_journal') + logger_journal.setLevel(logging.DEBUG) + + loggers['to_journal'] = logger_journal + + try: + journal_handler = JournaldLogHandler() + except NameError: + journal_handler = JournalHandler() + + journal_handler.setFormatter(formatter) + logger_journal.addHandler(journal_handler) + + debug = debug or self.debug + loggers['debug'] = debug + + return loggers + + def set_register(self): + if not self.dir['error'] or 'default' in self.dir['error']: + apps_path, apps_cred = self.apps['path'], self.apps['credentials'] + apps_extra_dir = self.apps['apps_extra_dir'] + pns_extra_dir = self.apps['pns_extra_dir'] + return get_pns_from_config(config_path=apps_path, + credentials=apps_cred, + apps_extra_dir=apps_extra_dir, + pns_extra_dir=pns_extra_dir, + loggers=self.loggers) + + @property + def pns_register(self): + return self.register['pns_register'] + + @property + def invalid_apps(self): + return self.register['invalid_apps'] + + @property + def pnses(self): + return self.register['pnses'] + + def set_allowed_pool(self): + if self.dir['error']: + return None + + if not self.file['path']: + return None + + allowed_pool = [] + allowed_hosts_str = '' + config = configparser.ConfigParser() + config.read(self.file['path']) + try: + allowed_hosts_str = config['server']['allowed_hosts'] + allowed_hosts = allowed_hosts_str.split(', ') + except KeyError: + return allowed_pool + except SyntaxError: + error = f'allowed_hosts = {allowed_hosts_str} - bad syntax' + self.dir['error'] = error + return allowed_pool + + if type(allowed_hosts) not in (list, tuple): + error = f'allowed_hosts = {allowed_hosts} - bad syntax' + self.dir['error'] = error + return allowed_pool + + config.read(self.file['path']) + for addr in allowed_hosts: + try: + net = f'{addr}/32' if '/' not in addr else addr + allowed_pool.append(ip_network(net)) + except ValueError as e: + error = f'wrong acl settings: {e}' + self.dir['error'] = error + return [] + + return set(allowed_pool) + + def set_return_async(self): + return_async = True + config = configparser.ConfigParser() + + if not self.file['error']: + config.read(self.file['path']) + try: + return_async = config['server']['return_async'] + return_async = True if return_async.lower() == 'true' else False + + except KeyError: + return_async = True + + return return_async + + +def init(config_dir, debug, ip, port): + global params + params = ConfigParams(config_dir, debug, ip, port) + return params + + +def update_params(config_dir, debug, ip, port): + global params + try: + params = ConfigParams(config_dir, debug, ip, port) + except Exception as ex: + print(f'Settings can not be updated, reason: {ex}') + return params diff --git a/pushserver/resources/utils.py b/pushserver/resources/utils.py new file mode 100644 index 0000000..01d28a5 --- /dev/null +++ b/pushserver/resources/utils.py @@ -0,0 +1,284 @@ +import hashlib +import json +import logging +import socket +import ssl +import time + +from ipaddress import ip_address +from datetime import datetime + +__all__ = ['callid_to_uuid', 'fix_non_serializable_types', 'resources_available', 'ssl_cert', 'try_again', 'check_host', + 'log_event', 'fix_device_id', 'fix_platform_name', 'log_incoming_request'] + + +def callid_to_uuid(call_id: str) -> str: + """ + + Generate a UUIDv4 from a callId. + + UUIDv4 format: five segments of seemingly random hex data, + beginning with eight hex characters, followed by three + four-character strings, then 12 characters at the end. + These segments are separated by a “-”. + + CallId string is concatenated with datetime str, + to add a pseudo random factor. + + :param call_id: `str` Globaly unique identifier of a call. + :return: a str with a uuidv4. + """ + d = datetime.now() + today = d.strftime("%Y%m%d%H%S%f") + + to_hash = f"{call_id}{today}" + hexa = hashlib.md5(to_hash.encode()).hexdigest() + + uuidv4 = '%s-%s-%s-%s-%s' % \ + (hexa[:8], hexa[8:12], hexa[12:16], hexa[16:20], hexa[20:]) + + return uuidv4 + + +def fix_non_serializable_types(obj): + """ + Converts a non serializable object in an appropriate one, + if it is possible and in a recursive way. + If not, return the str 'No JSON Serializable object' + + :param obj: obj to convert + """ + if isinstance(obj, bytes): + string = obj.decode() + return fix_non_serializable_types(string) + + elif isinstance(obj, dict): + return { + fix_non_serializable_types(k): fix_non_serializable_types(v) + for k, v in obj.items() + } + + elif isinstance(obj, (tuple, list)): + return [fix_non_serializable_types(elem) for elem in obj] + + elif isinstance(obj, str): + try: + dict_obj = json.loads(obj) + return fix_non_serializable_types(dict_obj) + except json.decoder.JSONDecodeError: + return obj + + elif isinstance(obj, (bool, int, float)): + return obj + + else: + return + + +def resources_available(host: str, port: int) -> bool: + """ + Check if a pair ip, port is available for a connection + :param: `str` host + :param: `int` port + :return: a `bool` according to the test result. + """ + serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if not host or not port: + return None + try: + serversocket.bind((host, port)) + serversocket.close() + return True + except OSError: + return False + + +def ssl_cert(cert_file: str, key_file=None) -> bool: + """ + Check if a ssl certificate is valid. + :param cert_file: `str` path to certificate file + :param key_file: `str` path to key file + :return: `bool` True for a valid certificate. + """ + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + try: + ssl_context.load_cert_chain(certfile=cert_file, keyfile=key_file) + return True + except (ssl.SSLError, NotADirectoryError, TypeError): + return False + + +def try_again(timer: int, host: str, port: int, + start_error: str, loggers: dict) -> None: + """ + Sleep for a specific time and send log messages + in case resources would not be available to start the app. + :param timer: `int` time in seconds to wait (30 = DHCP delay) + :param host: `str` IP address where app is trying to run + :param port: `int` Host where app is trying to run + :param start_error: `stṛ` Error msg to show in log. + :param loggers: global logging instances to write messages (params.loggers) + """ + timer = timer # seconds, 30 for dhcp delay. + level = 'error' + msg = f"[can not init] on {host}:{port} - resources are not available" + log_event(msg=start_error, level=level, loggers=loggers) + log_event(msg=msg, level=level, loggers=loggers) + msg = f'Server will try again in {timer} seconds' + log_event(msg=msg, level=level, loggers=loggers) + time.sleep(timer) + + +def check_host(host, allowed_hosts) -> bool: + """ + Check if a host is in allowed_hosts + :param host: `str` to check + :return: `bool` + """ + if not allowed_hosts: + return True + + for subnet in allowed_hosts: + if ip_address(host) in subnet: + return True + + return False + + +def log_event(loggers: dict, msg: str, level: str = 'deb', + to_file: bool = False) -> None: + """ + Write log messages into log file and in journal if specified. + :param loggers: `dict` global logging instances to write messages (params.loggers) + :param msg: `str` message to write + :param level: `str` info, error, deb or warn + :param to_file: `bool` write just in file if True + """ + + if loggers.get('to_file'): + logger = loggers.get('to_file') + else: + logger = loggers.get('to_journal') + + if len(logger.handlers) > 1: + logger.handlers = [logger.handlers[0]] + + msg = f'{datetime.now()} {msg}' + + if level == 'info': + logger.setLevel(logging.INFO) + logger.info(msg) + + elif level == 'error': + logger.setLevel(logging.ERROR) + logger.error(msg) + + elif level == 'warn': + logger.setLevel(logging.WARNING) + logger.warning(msg) + + elif level == 'deb': + logger.setLevel(logging.DEBUG) + logger.debug(msg) + + +def fix_device_id(device_id_to_fix: str) -> str: + """ + Remove special characters from uuid + :param device_id_to_fix: `str` uuid with special characters. + :return: a `str` with fixed uuid. + """ + if '>' in device_id_to_fix: + uuid = device_id_to_fix.split(':')[-1].replace('>', '') + elif ':' in device_id_to_fix: + uuid = device_id_to_fix.split(':')[-1] + else: + uuid = device_id_to_fix + device_id = uuid + return device_id + + +def fix_platform_name(platform: str) -> str: + """ + Fix platform name in case its value is 'android' or 'ios', + replacing it for 'firebase' and 'apple' + :param platform: `str` name of platform + :return: a `str` with fixed name. + """ + if platform in ('firebase', 'android'): + return 'firebase' + elif platform in ('apple', 'ios'): + return 'apple' + else: + return platform + + +def log_incoming_request(task: str, host: str, loggers: dict, + request_id: str = None, body: dict = None, + error_msg: str = None) -> None: + """ + Send log messages according to type of event. + :param task: `str` type of event to log, can be + 'log_request', 'log_success' or 'log_failure' + :param host: `str` client host where request comes from + :param loggers: `dict` global logging instances to write messages (params.loggers) + :param request_id: `str` request ID generated on request + :param body: `dict` body of request + :param error_msg: `str` to show in log + """ + app_id = body.get('app_id') + platform = body.get('platform') + platform = platform if platform else '' + sip_to = body.get('sip_to') + device_id = body.get('device_id') + device_id = fix_device_id(device_id) if device_id else None + event = body.get('event') + + if task == 'log_request': + payload = {} + for item in body.keys(): + value = body[item] + if item in ('sip_to', 'sip_from'): + item = item.split('_')[1] + else: + item = item.replace('_', '-') + payload[item] = value + + level = 'deb' + if sip_to: + if device_id: + msg = f'incoming {platform.title()} request {request_id}: ' \ + f'{event} for {sip_to} using' \ + f' device {device_id} from {host}: {payload}' + else: + msg = f'incoming {platform.title()} request {request_id}: ' \ + f'{event} for {sip_to} ' \ + f'from {host}: {payload}' + elif device_id: + msg = f'incoming {platform.title()} request {request_id}: ' \ + f'{event} using' \ + f' device {device_id} from {host}: {payload}' + else: + msg = f'incoming {platform.title()} request {request_id}: ' \ + f' from {host}: {payload}' + log_event(msg=msg, level=level, loggers=loggers, to_file=True) + + elif task == 'log_success': + msg = f'incoming {platform.title()} response for {request_id}: ' \ + f'push accepted' + level = 'info' + log_event(msg=msg, level=level, loggers=loggers) + + level = 'deb' + log_event(msg=msg, level=level, loggers=loggers, to_file=True) + + elif task == 'log_failure': + level = 'error' + resp = error_msg + msg = f'incoming {platform.title()} from {host} response for {request_id}, ' \ + f'push rejected: {resp}' + log_event(msg=msg, level=level, loggers=loggers) + log_event(loggers=loggers, msg=msg, level=level, to_file=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e6e4c5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi == 0.52.0 +hyper >= 0.7.0 +oauth2client >= 4.1.3 +pydantic >= 1.4 +pyinotify >= 0.9.6 +requests >= 2.23.0 +systemd >= 0.16.1 +uvicorn >= 0.11.3 \ No newline at end of file diff --git a/scripts/sylk-pushclient b/scripts/sylk-pushclient new file mode 100755 index 0000000..1b5ffc5 --- /dev/null +++ b/scripts/sylk-pushclient @@ -0,0 +1,92 @@ +#!/usr/bin/python + +import json +import logging +import re +import sys +import requests + +try: + import pymysql +except ImportError: + pass + +from argparse import ArgumentParser + + +if __name__ == '__main__': + parser = ArgumentParser(usage='%(prog)s [options]') + parser.add_argument('--url', dest='url', required=True, help='Push URL') + parser.add_argument('--platform', dest='platform', required=True, help='Platform') + parser.add_argument('--appid', dest='appid', required=True, help='App ID') + parser.add_argument('--token', dest='device_token', required=True, help='Device token') + parser.add_argument('--deviceid', dest='device_id', required=True, help='Device Id') + parser.add_argument('--callid', dest='call_id', required=True, help='Call ID') + parser.add_argument('--event', dest='event', required=False, help='Event', default='incoming_session') + parser.add_argument('--from', dest='from_uri', required=True, help='From') + parser.add_argument('--from_name', dest='from_name', required=False, help='From name') + parser.add_argument('--to', dest='to_uri', required=True, help='To') + parser.add_argument('--silent', dest='silent', default="1", required=False, help='Silent') + parser.add_argument('--mediatype', dest='media_type', default="audio", required=False, help='Audio, Video or Message') + options = parser.parse_args() + + from_uri = re.sub(r'^"|"$', '', options.from_uri) + from_name = options.from_name.strip('\"') if options.from_name else None + + try: + (token1, token2) = options.device_token.split("#") + except ValueError: + token1 = options.device_token + token2 = None + + media_type = options.media_type + + if ("video" in options.media_type): + media_type = 'video' + elif ("audio" in options.media_type): + media_type = 'audio' + + token = token2 if (token2 and options.event == 'cancel') else token1 + + log_params = { + 'platform': options.platform, + 'app-id': options.appid, + 'token': token, + 'media-type': media_type, + 'event': options.event, + 'from': from_uri, + 'from-display-name': from_name or from_uri, + 'to': options.to_uri, + 'device-id': options.device_id, + 'call-id': options.call_id, + 'silent': options.silent + } + + try: + r = requests.post(options.url, timeout=5, json=log_params) + if r.status_code == 200: + print("%s push for %s to %s response 200 OK: %s" % (options.event, options.call_id, options.url, r.text)) + body = r.json() + try: + failure = body['data']['body']['_content']['failure'] + if failure == 1: + # A push client may want to act based on various response codes + # https://firebase.google.com/docs/cloud-messaging/http-server-ref#error-codes + reason = body['data']['body']['_content']['results'][0]['error'] + if reason == 'NotRegistered': + log.info("Token %s must be purged" % token) + #q = "delete from push_tokens where token = '%s'" % token + #con = pymysql.connect('localhost', 'opensips', 'XYZ', 'opensips') + #with con: + # cur = con.cursor() + # cur.execute(q) + except KeyError: + pass + + sys.exit(0) + else: + print("%s push for %s to %s failed: %d: %s" % (options.event, options.call_id, options.url, r.status_code, r.text)) + sys.exit(1) + except Exception as e: + print("%s push for %s to %s failed: connection error" % (options.event, options.call_id, options.url)) + sys.exit(1) diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..7cbbd3c --- /dev/null +++ b/setup.py @@ -0,0 +1,54 @@ +#!/usr/bin/python3 +import glob + +from setuptools import setup + +import __info__ as package_info + +long_description = """ +Sylk Pushserver was designed to act as a central dispatcher for mobile push +notifications inside RTC provider infrastructures. Both the provider and +the mobile application customer, in the case of a shared infrastructure, can +easily audit problems related to the processing of push notifications. +""" + + +def requirements(): + install_requires = [] + with open('requirements.txt') as f: + for line in f: + install_requires.append(line.strip()) + return install_requires + + +setup(name=package_info.__project__, + version=package_info.__version__, + description=package_info.__summary__, + long_description=long_description, + author=package_info.__author__, + license=package_info.__license__, + platforms=['Platform Independent'], + author_email=package_info.__email__, + url=package_info.__webpage__, + scripts=['sylk-pushserver', 'scripts/sylk-pushclient'], + packages=['pushserver/api', + 'pushserver/api/errors', 'pushserver/api/routes', + 'pushserver/applications', 'pushserver/models', + 'pushserver/resources', 'pushserver/pns'], + install_requires=requirements(), + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Service Providers', + 'License :: GPL v3', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + ], + data_files=[('/etc/sylk-pushserver', []), + ('/etc/sylk-pushserver', glob.glob('config/*.sample')), + ('/etc/sylk-pushserver/credentials', []), + ('/etc/sylk-pushserver/applications', + glob.glob('config/applications/*.py')), + ('/etc/sylk-pushserver/applications/app_template', + glob.glob('config/applications/app_template/*.py')), + ('/var/log/sylk-pushserver', [])] + ) diff --git a/sylk-pushserver b/sylk-pushserver new file mode 100755 index 0000000..6c93eb2 --- /dev/null +++ b/sylk-pushserver @@ -0,0 +1,92 @@ +#!/usr/bin/python3 +import argparse +import datetime +import logging + +import uvicorn + +from pushserver.resources import settings +from pushserver.resources.utils import (log_event, resources_available, + ssl_cert, try_again) + +import logging +logging.getLogger("uvicorn").setLevel(logging.WARN) + +parser = argparse.ArgumentParser(add_help=False) +parser.add_argument('-h', '--help', + action='help', + default=argparse.SUPPRESS, + help='Show this help message and exit.') + +parser.add_argument("--ip", + default='', + help="If set, server will run in its address") + +parser.add_argument("--port", + default='', + help="If set, server will run in its address") + +parser.add_argument("--config_dir", + default='/etc/sylk-pushserver', + help="Specify a config directory that contains " + "config.ini and applications.ini files " + "and credentials directory, " + "'/etc/sylk-pushserver' by default.") + +parser.add_argument("--debug", + action="store_true", + default=False, + help="If set, log headers and body requests to log file.") + +args = parser.parse_args() + +logging.basicConfig(level=logging.INFO) +logging.info(f'{datetime.datetime.now()} Starting Sylk Pushserver...') + +settings.init(args.config_dir, args.debug, args.ip, args.port) + +if __name__ == '__main__': + + if not settings.params.dir['error'] or 'default' in settings.params.dir['error']: + sock_available = False + while not sock_available: + host = settings.params.server['host'] + port = int(settings.params.server['port']) + tls_cert = settings.params.server['tls_cert'] + sock_available = resources_available(host, port) + if sock_available: + if tls_cert: + if ssl_cert(tls_cert): + msg = f'Starting app over SSL...' + print(msg) + log_event(loggers=settings.params.loggers, + msg=msg, level='info') + uvicorn.run('pushserver.resources.app:app', host=host, + port=port, ssl_certfile=tls_cert, + acces_log=False, log_level='error') + break + else: + msg = f'{tls_cert} is not a valid ssl cert, app will be run without it' + print(msg) + log_event(loggers=settings.params.loggers, + msg=msg, level='deb') + uvicorn.run('pushserver.resources.server:server', + host=host, port=port, + access_log=False, log_level='error') + break + else: + uvicorn.run('pushserver.resources.server:server', + host=host, port=port, + access_log=False, log_level='error') + break + else: + try_again(timer=30, + host=host, port=port, + start_error=settings.params.dir['error'], + loggers=settings.params.loggers) + + else: + log_event(loggers=settings.params.loggers, + msg=settings.params.dir['error'], + level='error') + print(settings.params.dir['error']) diff --git a/sylk_pushserver.egg-info/PKG-INFO b/sylk_pushserver.egg-info/PKG-INFO new file mode 100644 index 0000000..e04b20a --- /dev/null +++ b/sylk_pushserver.egg-info/PKG-INFO @@ -0,0 +1,20 @@ +Metadata-Version: 1.1 +Name: sylk-pushserver +Version: 1.0.0 +Summary: Mobile push notifications for RTC infrastructures +Home-page: http://sylkserver.com +Author: Bibiana Rivadeneira +Author-email: support@ag-projects.com +License: GPL v3 +Description: + Sylk Pushserver was designed to act as a central dispatcher for mobile push + notifications inside RTC provider infrastructures. Both the provider and + the mobile application customer, in the case of a shared infrastructure, can + easily audit problems related to the processing of push notifications. + +Platform: Platform Independent +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Service Providers +Classifier: License :: GPL v3 +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python diff --git a/sylk_pushserver.egg-info/SOURCES.txt b/sylk_pushserver.egg-info/SOURCES.txt new file mode 100644 index 0000000..9e80203 --- /dev/null +++ b/sylk_pushserver.egg-info/SOURCES.txt @@ -0,0 +1,60 @@ +LICENSE +MANIFEST.in +README.md +__info__.py +debian-requirements.txt +requirements.txt +setup.py +sylk-pushserver +config/applications.ini.sample +config/general.ini.sample +config/opensips.cfg +config/applications/__init__.py +config/applications/myapp.py +config/pns/__init__.py +config/pns/mypns.py +debian/changelog +debian/compat +debian/control +debian/copyright +debian/dirs +debian/docs +debian/logrotate +debian/rules +debian/sylk-pushserver.examples +debian/sylk-pushserver.install +debian/sylk-pushserver.links +debian/sylk-pushserver.service +debian/source/format +debian/source/lintian-overrides +pushserver/__init__.py +pushserver/api/__init__.py +pushserver/api/errors/__init__.py +pushserver/api/errors/validation_error.py +pushserver/api/routes/__init__.py +pushserver/api/routes/api.py +pushserver/api/routes/home.py +pushserver/api/routes/push.py +pushserver/applications/__init__.py +pushserver/applications/apple.py +pushserver/applications/firebase.py +pushserver/applications/linphone.py +pushserver/applications/sylk.py +pushserver/models/__init__.py +pushserver/models/requests.py +pushserver/pns/__init__.py +pushserver/pns/apple.py +pushserver/pns/base.py +pushserver/pns/firebase.py +pushserver/pns/register.py +pushserver/resources/__init__.py +pushserver/resources/notification.py +pushserver/resources/server.py +pushserver/resources/settings.py +pushserver/resources/utils.py +scripts/sylk-pushclient +sylk_pushserver.egg-info/PKG-INFO +sylk_pushserver.egg-info/SOURCES.txt +sylk_pushserver.egg-info/dependency_links.txt +sylk_pushserver.egg-info/requires.txt +sylk_pushserver.egg-info/top_level.txt \ No newline at end of file diff --git a/sylk_pushserver.egg-info/dependency_links.txt b/sylk_pushserver.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sylk_pushserver.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/sylk_pushserver.egg-info/requires.txt b/sylk_pushserver.egg-info/requires.txt new file mode 100644 index 0000000..af36bb9 --- /dev/null +++ b/sylk_pushserver.egg-info/requires.txt @@ -0,0 +1,8 @@ +fastapi==0.52.0 +hyper>=0.7.0 +oauth2client>=4.1.3 +pydantic>=1.4 +pyinotify>=0.9.6 +requests>=2.23.0 +systemd>=0.16.1 +uvicorn>=0.11.3 diff --git a/sylk_pushserver.egg-info/top_level.txt b/sylk_pushserver.egg-info/top_level.txt new file mode 100644 index 0000000..53d6fb7 --- /dev/null +++ b/sylk_pushserver.egg-info/top_level.txt @@ -0,0 +1,7 @@ +pushserver/api +pushserver/api/errors +pushserver/api/routes +pushserver/applications +pushserver/models +pushserver/pns +pushserver/resources