├── .clang-format ├── .gitignore ├── .gitmodules ├── CMakeLists.txt ├── DOCUMENTATION.md ├── LICENSE.txt ├── Makefile ├── README.md ├── format.sh ├── hms ├── LICENSE ├── README.md ├── README_ZH.md ├── requirements.txt ├── setup.py ├── src │ └── push_admin │ │ ├── __init__.py │ │ ├── _app.py │ │ ├── _http.py │ │ ├── _message_serializer.py │ │ ├── _messages.py │ │ └── messaging.py └── test │ ├── push_env.py │ ├── send_apns_message.py │ ├── send_condition_message.py │ ├── send_data_message.py │ ├── send_instance_app_message.py │ ├── send_notifiy_message.py │ ├── send_test_message.py │ ├── send_topic_message.py │ └── send_webpush_message.py ├── make-x25519-key.py ├── spns.ini.example ├── spns ├── __init__.py ├── blake2b.hpp ├── bytes.hpp ├── config.hpp ├── config.py ├── hive │ ├── signature.cpp │ ├── signature.hpp │ ├── snode.cpp │ ├── snode.hpp │ ├── subscription.cpp │ └── subscription.hpp ├── hivemind.cpp ├── hivemind.hpp ├── hivemind.py ├── notifiers │ ├── apns.py │ ├── apns_sandbox.py │ ├── dummy.py │ ├── fcm.py │ ├── firebase.py │ ├── huawei.py │ └── util.py ├── onion_request.py ├── pg.cpp ├── pg.hpp ├── pybind.cpp ├── register.py ├── schema-upgrades.pgsql ├── schema.pgsql ├── subrequest.py ├── swarmpubkey.cpp ├── swarmpubkey.hpp ├── utils.cpp ├── utils.hpp └── web.py ├── systemd ├── spns-hivemind.service ├── spns-notifier@.service └── spns.target ├── tests ├── test_const.py ├── test_databaseHelperV2.py ├── test_main.py ├── test_pushNotificationHandler.py └── test_server.py └── uwsgi-spns.ini /.clang-format: -------------------------------------------------------------------------------- 1 | 2 | BasedOnStyle: Google 3 | AlignAfterOpenBracket: AlwaysBreak 4 | AlignConsecutiveAssignments: 'false' 5 | AlignConsecutiveDeclarations: 'false' 6 | AlignEscapedNewlines: Left 7 | AlignOperands: AlignAfterOperator 8 | AlignTrailingComments: 'true' 9 | AllowAllArgumentsOnNextLine: 'true' 10 | AllowShortBlocksOnASingleLine: 'false' 11 | AllowShortCaseLabelsOnASingleLine: 'true' 12 | AllowShortFunctionsOnASingleLine: Inline 13 | AllowShortIfStatementsOnASingleLine: 'false' 14 | AllowShortLoopsOnASingleLine: 'false' 15 | AlwaysBreakAfterReturnType: None 16 | AlwaysBreakTemplateDeclarations: Yes 17 | BreakBeforeBinaryOperators: None 18 | BreakBeforeBraces: Attach 19 | BreakBeforeTernaryOperators: 'true' 20 | BreakConstructorInitializers: AfterColon 21 | Cpp11BracedListStyle: 'true' 22 | KeepEmptyLinesAtTheStartOfBlocks: 'true' 23 | NamespaceIndentation: Inner 24 | CompactNamespaces: 'true' 25 | PenaltyBreakString: '3' 26 | SpaceBeforeParens: ControlStatements 27 | SpacesInAngles: 'false' 28 | SpacesInContainerLiterals: 'false' 29 | SpacesInParentheses: 'false' 30 | SpacesInSquareBrackets: 'false' 31 | Standard: c++17 32 | UseTab: Never 33 | SortIncludes: true 34 | ColumnLimit: 100 35 | IndentWidth: 4 36 | AccessModifierOffset: -2 37 | ConstructorInitializerIndentWidth: 8 38 | ContinuationIndentWidth: 8 39 | 40 | 41 | # treat pointers and reference declarations as if part of the type 42 | DerivePointerAlignment: false 43 | PointerAlignment: Left 44 | 45 | # when wrapping function calls/declarations, force each parameter to have its own line 46 | BinPackParameters: 'false' 47 | BinPackArguments: 'false' 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | # IDEA 109 | .idea 110 | *.iml 111 | 112 | #Certificates 113 | *.pem 114 | *.json 115 | 116 | # Logs 117 | apns.log* 118 | spns.log* 119 | 120 | # Database file 121 | *_db* 122 | *.db* 123 | 124 | # Runtime config and keys: 125 | /spns.ini 126 | /*_x25519 127 | 128 | generate_cert.txt 129 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "oxen-logging"] 2 | path = oxen-logging 3 | url = https://github.com/oxen-io/oxen-logging.git 4 | [submodule "libpqxx"] 5 | path = libpqxx 6 | url = https://github.com/jtv/libpqxx.git 7 | [submodule "pybind11"] 8 | path = pybind11 9 | url = https://github.com/pybind/pybind11.git 10 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | 2 | cmake_minimum_required(VERSION 3.18) 3 | 4 | set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 5 | 6 | find_program(CCACHE_PROGRAM ccache) 7 | if(CCACHE_PROGRAM) 8 | foreach(lang C CXX) 9 | if(NOT DEFINED CMAKE_${lang}_COMPILER_LAUNCHER AND NOT CMAKE_${lang}_COMPILER MATCHES ".*/ccache") 10 | message(STATUS "Enabling ccache for ${lang}") 11 | set(CMAKE_${lang}_COMPILER_LAUNCHER ${CCACHE_PROGRAM} CACHE STRING "") 12 | endif() 13 | endforeach() 14 | endif() 15 | 16 | project(spns) 17 | 18 | set(CMAKE_CXX_STANDARD 17) 19 | set(CMAKE_CXX_STANDARD_REQUIRED ON) 20 | set(CMAKE_CXX_EXTENSIONS OFF) 21 | set(CMAKE_POSITION_INDEPENDENT_CODE ON) 22 | 23 | add_library(spns STATIC 24 | spns/hivemind.cpp 25 | spns/pg.cpp 26 | spns/swarmpubkey.cpp 27 | spns/utils.cpp 28 | spns/hive/signature.cpp 29 | spns/hive/snode.cpp 30 | spns/hive/subscription.cpp 31 | ) 32 | 33 | find_package(PkgConfig REQUIRED) 34 | 35 | pkg_check_modules(SODIUM REQUIRED IMPORTED_TARGET libsodium>=1.0.18) 36 | pkg_check_modules(OXENC REQUIRED IMPORTED_TARGET liboxenc>=1.0.4) 37 | pkg_check_modules(OXENMQ REQUIRED IMPORTED_TARGET liboxenmq>=1.2.14) 38 | pkg_check_modules(NLOHMANN_JSON REQUIRED IMPORTED_TARGET nlohmann_json>=3.7.0) 39 | pkg_check_modules(SYSTEMD REQUIRED IMPORTED_TARGET libsystemd) 40 | 41 | if(CMAKE_VERSION VERSION_LESS "3.21") 42 | # Work around cmake bug 22180 (PkgConfig::THING not set if no flags needed) 43 | add_library(_deps_dummy INTERFACE) 44 | foreach(pkg OXENC NLOHMANN_JSON) 45 | if(NOT TARGET PkgConfig::${pkg}) 46 | add_library(PkgConfig::${pkg} ALIAS _deps_dummy) 47 | endif() 48 | endforeach() 49 | endif() 50 | 51 | option(SUBMODULE_CHECK "Enables checking that vendored library submodules are up to date" ON) 52 | if(SUBMODULE_CHECK) 53 | find_package(Git) 54 | if(GIT_FOUND) 55 | function(check_submodule relative_path) 56 | execute_process(COMMAND git rev-parse "HEAD" WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/${relative_path} OUTPUT_VARIABLE localHead) 57 | execute_process(COMMAND git rev-parse "HEAD:${relative_path}" WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} OUTPUT_VARIABLE checkedHead) 58 | string(COMPARE EQUAL "${localHead}" "${checkedHead}" upToDate) 59 | if (upToDate) 60 | message(STATUS "Submodule '${relative_path}' is up-to-date") 61 | else() 62 | message(FATAL_ERROR "Submodule '${relative_path}' is not up-to-date. Please update with\ngit submodule update --init --recursive\nor run cmake with -DSUBMODULE_CHECK=OFF") 63 | endif() 64 | 65 | # Extra arguments check nested submodules 66 | foreach(submod ${ARGN}) 67 | execute_process(COMMAND git rev-parse "HEAD" WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/${relative_path}/${submod} OUTPUT_VARIABLE localHead) 68 | execute_process(COMMAND git rev-parse "HEAD:${submod}" WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/${relative_path} OUTPUT_VARIABLE checkedHead) 69 | string(COMPARE EQUAL "${localHead}" "${checkedHead}" upToDate) 70 | if (NOT upToDate) 71 | message(FATAL_ERROR "Nested submodule '${relative_path}/${submod}' is not up-to-date. Please update with\ngit submodule update --init --recursive\nor run cmake with -DSUBMODULE_CHECK=OFF") 72 | endif() 73 | endforeach() 74 | endfunction () 75 | 76 | message(STATUS "Checking submodules") 77 | check_submodule(oxen-logging fmt spdlog) 78 | check_submodule(libpqxx) 79 | check_submodule(pybind11) 80 | 81 | endif() 82 | endif() 83 | 84 | set(OXEN_LOGGING_SOURCE_ROOT "${PROJECT_SOURCE_DIR}" CACHE INTERNAL "") 85 | add_subdirectory(oxen-logging) 86 | 87 | add_subdirectory(libpqxx EXCLUDE_FROM_ALL) 88 | 89 | target_link_libraries(spns PRIVATE 90 | PkgConfig::SODIUM 91 | PkgConfig::OXENC 92 | PkgConfig::OXENMQ 93 | PkgConfig::NLOHMANN_JSON 94 | PkgConfig::SYSTEMD 95 | PUBLIC 96 | pqxx 97 | oxen::logging) 98 | 99 | set_target_properties(spns PROPERTIES INTERPROCEDURAL_OPTIMIZATION ON) 100 | 101 | set(PYBIND11_FINDPYTHON ON CACHE INTERNAL "") 102 | add_subdirectory(pybind11) 103 | pybind11_add_module( 104 | core 105 | spns/pybind.cpp) 106 | 107 | target_link_libraries(core PUBLIC spns) 108 | set_target_properties(core PROPERTIES 109 | LIBRARY_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/spns) 110 | -------------------------------------------------------------------------------- /DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | - PN subscriptions go to the `/subscribe` endpoint on 4 | 5 | https://push.getsession.org 6 | 7 | via a v4 onion request. The onion req pubkey is: d7557fe563e2610de876c0ac7341b62f3c82d5eea4b62c702392ea4368f51b3b 8 | 9 | - The JSON payload looks like this: 10 | 11 | { 12 | "pubkey": "05123...", 13 | "session_ed25519": "abc123...", 14 | "subkey_tag": "def789...", 15 | "namespaces": [-400,0,1,2,17], 16 | "data": true, 17 | "sig_ts": 1677520760, 18 | "signature": "f8efdd120007...", 19 | "service": "apns", 20 | "service_info": { "token": "xyz123..." }, 21 | "enc_key": "abcdef..." 22 | } 23 | 24 | where keys are as follows (note that all bytes values shown above in hex can be passed either as 25 | hex or base64): 26 | 27 | - `pubkey` -- the 33-byte account being subscribed to; typically a session ID. 28 | - `session_ed25519` -- when the `pubkey` value starts with 05 (i.e. a session ID) this is the 29 | underlying ed25519 32-byte pubkey associated with the session ID. When not 05, this field 30 | should not be provided. 31 | - `subkey_tag` -- 32-byte swarm authentication subkey; omitted (or null) when not using subkey 32 | auth 33 | - `namespaces` -- list of integer namespace (-32768 through 32767). These must be sorted in 34 | ascending order. 35 | - `data` -- if provided and true then notifications will include the body of the message (as long 36 | as it isn't too large); if false then the body will not be included in notifications. 37 | - `sig_ts` -- the signature unix timestamp (seconds, not ms); see below. 38 | - `signature` -- the 64-byte Ed25519 signature; see below. 39 | - `service` -- the string identifying the notification service, such as "apns" or "firebase". 40 | - `service_info` -- dict of service-specific data; typically this includes just a "token" field 41 | with a device-specific token, but different services in the future may have different input 42 | requirements. 43 | - `enc_key` -- 32-byte encryption key; notification payloads sent to the device will be encrypted 44 | with XChaCha20-Poly1305 using this key. Though it is permitted for this to change, it is 45 | recommended that the device generate this once and persist it. 46 | 47 | Notification subscriptions are unique per pubkey/service/service_token which means that 48 | re-subscribing with the same pubkey/service/token renews (or updates, if there are changes in 49 | other parameters such as the namespaces) an existing subscription. 50 | 51 | Signatures: 52 | 53 | The signature data collected and stored here is used by the PN server to subscribe to the swarms 54 | for the given account; the specific rules are governed by the storage server, but in general: 55 | 56 | - a signature must have been produced (via the timestamp) within the past 14 days. It is 57 | recommended that clients generate a new signature whenever they re-subscribe, and that 58 | re-subscriptions happen more frequently than once every 14 days. 59 | 60 | - a signature is signed using the account's Ed25519 private key (or Ed25519 subkey, if using 61 | subkey authentication with a subkey_tag, for future closed group subscriptions), and signs the value: 62 | 63 | "MONITOR" || HEX(ACCOUNT) || SIG_TS || DATA01 || NS[0] || "," || ... || "," || NS[n] 64 | 65 | where SIG_TS is the `sig_ts` value as a base-10 string; DATA01 is either "0" or "1" depending 66 | on whether the subscription wants message data included; and the trailing NS[i] values are a 67 | comma-delimited list of namespaces that should be subscribed to, in the same sorted order as 68 | the `namespaces` parameter. 69 | 70 | Returns json such as: 71 | 72 | { "success": true, "added": true } 73 | 74 | on acceptance of a new registration, or: 75 | 76 | { "success": true, "updated": true } 77 | 78 | on renewal/update of an existing device registration. 79 | 80 | On error returns: 81 | 82 | { "error": CODE, "message": "some error description" } 83 | 84 | where CODE is one of the integer values of the spns/hive/subscription.hpp SUBSCRIBE enum, here: 85 | https://github.com/jagerman/session-push-notification-server/blob/spns-v2/spns/hive/subscription.hpp#L21 86 | 87 | 88 | - Notifications when received now look like this (APNS): 89 | 90 | { 91 | 'aps': { 92 | "alert": {"title": "Session", "body": "You've got a new message"}, 93 | "badge": 1, 94 | "sound": "default", 95 | "mutable-content": 1, 96 | "category": "SECRET", 97 | }, 98 | 'enc_payload': B64(NONCE+ENCRYPTED(l123:{...json...}456:...msg...e)), 99 | 'spns': 1 100 | } 101 | 102 | That is: 103 | - `aps` is some required Apple junk. 104 | - `spns` is a version counter, currently 1, but will be incremented if we make significant future 105 | changes to the notification protocol. 106 | - `enc_payload` is a base64-encoded value which, in decoded (binary) form, is: 107 | - 24 bytes of NONCE 108 | - however many bytes of encryption data 109 | - the encryption data, once decrypted, is a 1- or 2-element bencoded list, where: 110 | - element [0] is the notification metadata (in JSON) 111 | - element [1] is the message data (in bytes). 112 | 113 | - Notification metadata is JSON with keys (single-letter to minimize overhead in the size-limited 114 | push messages): 115 | 116 | "@" - the session ID (hex) 117 | "#" - the storage server message hash 118 | "n" - the namespace (integer) 119 | "l" - the byte length of the message data 120 | "B" - will be present and set to true if the message data was too long for inclusion in the 121 | notification, omitted otherwise. 122 | 123 | Both "l" and "B" will be omitted if the subscription opted out of data (i.e. if it passed 124 | `"data": false). 125 | 126 | - Assuming the user subscribed to message data and the data is not too long (2.5kB, since we also 127 | have to go through base64 encoding after encryption and still end up <4kB), the message data 128 | will then be included as bytes as the second element of the enc_payload bt-encoded list. 129 | 130 | (If the user didn't want data, or the data was too big, then the enc_payload will only have the 131 | metadata object). 132 | 133 | - For Android, the subscription request uses `"service": "firebase"`, and the request data is the 134 | same as iOS except with the Apple-specific `"aps"` key omitted (that is: it has the `enc_payload` 135 | and `spns` keys, as described above). 136 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: spns clean 3 | spns: 4 | ifeq (,$(wildcard ./build)) 5 | mkdir -p build 6 | cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release \ 7 | -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON \ 8 | -DCMAKE_POLICY_DEFAULT_CMP0069=NEW 9 | 10 | endif 11 | $(MAKE) -C build 12 | 13 | clean: 14 | rm -rf build spns/core.cpython*.so 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Repository Deprecated 2 | 3 | ## This repository is now deprecated. However, The Session Push Notification Server is still actively developed [here](https://github.com/session-foundation/session-push-notification-server). This is in line with announcements from [Session](https://getsession.org/blog/introducing-the-session-technology-foundation) and the [OPTF](https://optf.ngo/blog/the-optf-and-session), indicating that the OPTF has handed over the stewardship of the Session Project to the [Session Technology Foundation](https://session.foundation), a Swiss-based foundation dedicated to advancing digital rights and innovation. 4 | -------------------------------------------------------------------------------- /format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$1" == "--check" ]; then 4 | cf_args=(--dry-run -Werror) 5 | black_args=(--check) 6 | elif [ "$#" -eq 0 ]; then 7 | cf_args=(-i) 8 | black_args=() 9 | else 10 | echo "Usage: $0 [--check]" >&2 11 | exit 1 12 | fi 13 | 14 | CLANG_FORMAT_DESIRED_VERSION=14 15 | 16 | CLANG_FORMAT=$(command -v clang-format-$CLANG_FORMAT_DESIRED_VERSION 2>/dev/null) 17 | if [ $? -ne 0 ]; then 18 | CLANG_FORMAT=$(command -v clang-format-mp-$CLANG_FORMAT_DESIRED_VERSION 2>/dev/null) 19 | fi 20 | if [ $? -ne 0 ]; then 21 | CLANG_FORMAT=$(command -v clang-format 2>/dev/null) 22 | if [ $? -ne 0 ]; then 23 | echo "Please install clang-format version $CLANG_FORMAT_DESIRED_VERSION and re-run this script." >&2 24 | exit 1 25 | fi 26 | version=$(clang-format --version) 27 | if [[ ! $version == *"clang-format version $CLANG_FORMAT_DESIRED_VERSION"* ]]; then 28 | echo "Please install clang-format version $CLANG_FORMAT_DESIRED_VERSION and re-run this script." >&2 29 | exit 1 30 | fi 31 | fi 32 | 33 | BLACK=$(command -v black 2>/dev/null) 34 | if [ $? -ne 0 ]; then 35 | echo "Please install the 'black' python3 package and make sure it is available in your path" 36 | fi 37 | 38 | shopt -s globstar 39 | bad=0 40 | 41 | $CLANG_FORMAT "${cf_args[@]}" spns/**/*.[ch]pp 42 | if [ $? -ne 0 ]; then 43 | bad=1 44 | fi 45 | 46 | black "${black_args[@]}" spns/**/*.py 47 | if [ $? -ne 0 ]; then 48 | bad=1 49 | fi 50 | 51 | exit $bad 52 | -------------------------------------------------------------------------------- /hms/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 16 | 17 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 18 | 19 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 20 | 21 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 22 | 23 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 24 | 25 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 26 | 27 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 28 | 29 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 30 | 31 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 32 | 33 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 34 | 35 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 36 | 37 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 38 | You must cause any modified files to carry prominent notices stating that You changed the files; and 39 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 40 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 41 | 42 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 43 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 44 | 45 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 46 | 47 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 48 | 49 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 50 | 51 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 52 | 53 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /hms/README.md: -------------------------------------------------------------------------------- 1 | ## HMS PushKit Python Severdemo 2 | English | [中文](https://github.com/HMS-Core/hms-push-serverdemo-python/blob/master/python37/README_ZH.md) 3 | 4 | ## Table of Contents 5 | 6 | * [Introduction](#introduction) 7 | * [Installation](#installation) 8 | * [Configuration ](#configuration ) 9 | * [Supported Environments](#supported-environments) 10 | * [Sample Code](#sample-code) 11 | * [Libraries](#Libraries) 12 | * [License](#license) 13 | 14 | 15 | ## Introduction 16 | 17 | Python sample code encapsulates APIs of the HUAWEI Push Kit server. It provides many sample programs about quick access to HUAWEI Push Kit for your reference or usage. 18 | 19 | The following table describes packages of Python sample code. 20 | 21 | | Package | Description | 22 | | ---------- | ------------| 23 | | examples | Sample code packages. Each package can run independently.| 24 | | push_admin | Package where APIs of the HUAWEI Push Kit server are encapsulated.| 25 | 26 | ## Installation 27 | 28 | To install pushkit-python-sample, you should extract the compressed ZIP file, execute the following command in the unzipped directory: 29 | ``` 30 | python setup.py install 31 | ``` 32 | 33 | ## Supported Environments 34 | For pushkit-python-sample, We currently support Python 2.7/3.7 and JetBrains PyCharm are recommended. 35 | 36 | 37 | ## Configuration 38 | The following table describes parameters of the initialize_app method. 39 | 40 | | Parameter | Description | 41 | | ------------- | ------------------------------------------------------------------------- | 42 | | appid | App ID, which is obtained from app information. | 43 | | appsecret | Secret access key of an app, which is obtained from app information. | 44 | | token_server | URL for the Huawei OAuth 2.0 service to obtain a token, please refer to [Generating an App-Level Access Token](https://developer.huawei.com/consumer/en/doc/development/HMSCore-Guides/oauth2-0000001212610981). | 45 | | push_open_url | URL for accessing HUAWEI Push Kit, please refer to [Sending Messages](https://developer.huawei.com/consumer/en/doc/development/HMSCore-Guides/android-server-dev-0000001050040110?ha_source=hms1).|| 46 | 47 | 48 | ## Sample Code 49 | 50 | Python sample code uses the Messaging structure in the push_admin package as the entry. Each method in the Messaging 51 | structure calls an API of the HUAWEI Push Kit server. 52 | 53 | The following table describes methods in the Messaging structure. 54 | 55 | | Method | Description 56 | | ----------------- | --------------------------------------------------- | 57 | | send_message | Sends a message to a device. | 58 | | subscribe_topic | Subscribes to a topic. | 59 | | unsubscribe_topic | Unsubscribes from a topic. | 60 | | list_topics | Queries the list of topics subscribed by a device. | 61 | | initialize_app | Initializes the configuration parameters. | 62 | 63 | 64 | 1) Send an Android data message. 65 | Code location: examples/send_data_message.py 66 | 67 | 2) Send an Android notification message. 68 | Code location: examples/send_notify_message.py 69 | 70 | 3) Send a message by topic. 71 | Code location: examples/send_topic_message.py 72 | 73 | 4) Send a message by conditions. 74 | Code location: examples/send_condition_message.py 75 | 76 | 5) Send a message to a Huawei quick app. 77 | Code location: examples/send_instance_app_message.py 78 | 79 | 6) Send a message through the WebPush agent. 80 | Code location: examples/send_webpush_message.py 81 | 82 | 7) Send a message through the APNs agent. 83 | Code location: examples/send_apns_message.py 84 | 85 | 8) Send a test message. 86 | Code location: examples/send_test_message.py 87 | 88 | ## Libraries 89 | | Library | Site 90 | | ----------------- | --------------------------------------------------- | 91 | | requests | https://requests.readthedocs.io/en/master/ | 92 | | six | https://six.readthedocs.io/ | 93 | ## License 94 | 95 | pushkit Python sample is licensed under the [Apache License, version 2.0](http://www.apache.org/licenses/LICENSE-2.0). 96 | -------------------------------------------------------------------------------- /hms/README_ZH.md: -------------------------------------------------------------------------------- 1 | ## 华为推送服务服务端Python示例代码 2 | [English](https://github.com/HMS-Core/hms-push-serverdemo-python/tree/master/python37) | 中文 3 | ## 目录 4 | * [简介](#简介) 5 | * [安装](#安装) 6 | * [环境要求](#环境要求) 7 | * [配置](#配置) 8 | * [示例代码](#示例代码) 9 | * [知识库](#知识库) 10 | * [授权许可](#授权许可) 11 | 12 | ## 简介 13 | 14 | Python示例代码对华为推送服务(HUAWEI Push Kit)服务端接口进行封装,包含丰富的示例程序,方便您参考或直接使用。 15 | 16 | 示例代码主要包括以下组件: 17 | 18 | | 包名 | 说明 | 19 | | ---------- | ------------| 20 | | examples | 示例代码包,每个包都可以独立运行 | 21 | | push_admin | 推送服务的服务端接口封装包 | 22 | 23 | ## 安装 24 | 25 | 安装本示例代码前,请解压zip文件包,并在解压后的文件目录中执行以下命令: 26 | ``` 27 | python setup.py install 28 | ``` 29 | 30 | ## 环境要求 31 | Python 2.7/3.7 32 | JetBrains PyCharm(推荐使用) 33 | 34 | 35 | ## 配置 36 | initialize_app方法包括如下参数: 37 | 38 | | 参数 | 说明 | 39 | | ------------- | ------------------------------------------------------------------------- | 40 | | appid | 应用ID,从应用消息中获取 | 41 | | appsecret | 应用访问密钥,从应用信息中获取 | 42 | | token_server | 华为OAuth 2.0获取token的地址。具体请参考[基于OAuth 2.0开放鉴权-客户端模式](https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/oauth2-0000001212610981)。| 43 | | push_open_url | 推送服务的访问地址。具体请参考[推送服务-下行消息](https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/android-server-dev-0000001050040110?ha_source=hms1)。| 44 | 45 | 46 | ## 示例代码 47 | 48 | 本示例代码以push_admin包中的Messaging结构体为入口。Messaging结构体中的方法完成了对推送服务服务端接口的调用。 49 | 50 | Messaging包括如下方法: 51 | 52 | | 方法 | 说明 53 | | ----------------- | --------------------------------------------------- | 54 | | send_message | 向设备发送消息 | 55 | | subscribe_topic | 订阅主题 | 56 | | unsubscribe_topic | 退订主题 | 57 | | list_topics | 查询设备订阅的主题列表 | 58 | | initialize_app | 初始化配置参数 | 59 | 60 | 61 | 1) 发送Android透传消息 62 | 代码位置: examples/send_data_message.py 63 | 64 | 2) 发送Android通知栏消息 65 | 代码位置: examples/send_notify_message.py 66 | 67 | 3) 基于主题发送消息 68 | 代码位置: examples/send_topic_message.py 69 | 70 | 4) 基于条件发送消息 71 | 代码位置: examples/send_condition_message.py 72 | 73 | 5) 向华为快应用发送消息 74 | 代码位置: examples/send_instance_app_message.py 75 | 76 | 6) 基于WebPush代理发送消息 77 | 代码位置: examples/send_webpush_message.py 78 | 79 | 7) 基于APNs代理发送消息 80 | 代码位置: examples/send_apns_message.py 81 | 82 | 8) 发送测试消息 83 | 代码位置: examples/send_test_message.py 84 | 85 | ## 知识库 86 | | 知识库 | 地址 87 | | ----------------- | --------------------------------------------------- | 88 | | requests | https://requests.readthedocs.io/en/master/ | 89 | | six | https://six.readthedocs.io/ | 90 | 91 | ## 授权许可 92 | 华为推送服务Python示例代码经过[Apache License, version 2.0](http://www.apache.org/licenses/LICENSE-2.0)授权许可。 93 | -------------------------------------------------------------------------------- /hms/requirements.txt: -------------------------------------------------------------------------------- 1 | requests >= 2.20.1 2 | six >= 1.14.0 -------------------------------------------------------------------------------- /hms/setup.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # refer. https://wiki.python.org/moin/Distutils/Tutorial?highlight=%28setup.py%29 18 | # 19 | from setuptools import setup 20 | 21 | __version__ = '1.0.0' 22 | __title__ = 'hcm_admin' 23 | __author__ = 'Huawei' 24 | __license__ = 'Apache License 2.0' 25 | __url__ = 'https://developer.huawei.com/consumer/cn/' 26 | 27 | install_requires = ['requests>=2.20.1'] 28 | 29 | long_description = ('The Huawei Admin Python SDK enables server-side (backend) Python developers ' 30 | 'to integrate Huawei into their services and applications.') 31 | 32 | setup( 33 | name='huawei_push_admin', 34 | version='1.0.0', 35 | description='Huawei Admin Python SDK', 36 | long_description=long_description, 37 | url='https://developer.huawei.com/consumer/cn/', 38 | author='Huawei', 39 | license='Apache License 2.0', 40 | keywords='huawei cloud development', 41 | install_requires=install_requires, 42 | packages=['src/push_admin'], 43 | python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', 44 | classifiers=[ 45 | 'Development Status :: 5 - Production/Stable', 46 | 'Intended Audience :: Developers', 47 | 'Topic :: Software Development :: Build Tools', 48 | 'Programming Language :: Python :: 2', 49 | 'Programming Language :: Python :: 2.7', 50 | 'Programming Language :: Python :: 3', 51 | 'Programming Language :: Python :: 3.4', 52 | 'Programming Language :: Python :: 3.5', 53 | 'Programming Language :: Python :: 3.6', 54 | 'Programming Language :: Python :: 3.7', 55 | 'License :: OSI Approved :: Apache Software License', 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /hms/src/push_admin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | """Huawei Admin SDK for Python.""" 18 | 19 | import threading 20 | from hms.src.push_admin import _app 21 | 22 | _apps = {} 23 | _apps_lock = threading.RLock() 24 | _DEFAULT_APP_NAME = 'DEFAULT' 25 | 26 | 27 | def initialize_app(appid_at, appsecret_at, appid_push=None, token_server='https://oauth-login.cloud.huawei.com/oauth2/v2/token', 28 | push_open_url='https://push-api.cloud.huawei.com'): 29 | """ 30 | Initializes and returns a new App instance. 31 | :param appid_at: appid parameters obtained by developer alliance applying for Push service 32 | :param appsecret_at: appsecret parameters obtained by developer alliance applying for Push service 33 | :param appid_push: the application Id in the URL 34 | :param token_server: Oauth server URL 35 | :param push_open_url: push open API URL 36 | """ 37 | app = _app.App(appid_at, appsecret_at, appid_push, token_server=token_server, push_open_url=push_open_url) 38 | 39 | with _apps_lock: 40 | if appid_at not in _apps: 41 | _apps[appid_at] = app 42 | 43 | """set default app instance""" 44 | if _apps.get(_DEFAULT_APP_NAME) is None: 45 | _apps[_DEFAULT_APP_NAME] = app 46 | 47 | 48 | def get_app(appid=None): 49 | """ 50 | get app instance 51 | :param appid: appid parameters obtained by developer alliance applying for Push service 52 | :return: app instance 53 | Raise: ValueError 54 | """ 55 | if appid is None: 56 | with _apps_lock: 57 | app = _apps.get(_DEFAULT_APP_NAME) 58 | if app is None: 59 | raise ValueError('The default Huawei app is not exists. ' 60 | 'This means you need to call initialize_app() it.') 61 | return app 62 | 63 | with _apps_lock: 64 | if appid not in _apps: 65 | raise ValueError('Huawei app id[{0}] is not exists. ' 66 | 'This means you need to call initialize_app() it.'.format(appid)) 67 | 68 | app = _apps.get(appid) 69 | if app is None: 70 | raise ValueError('The app id[{0}] is None.'.format(appid)) 71 | return app 72 | -------------------------------------------------------------------------------- /hms/src/push_admin/_app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import json 18 | import time 19 | import urllib 20 | import urllib.parse 21 | 22 | from hms.src.push_admin import _http 23 | from hms.src.push_admin import _message_serializer 24 | 25 | 26 | class App(object): 27 | """application for HW Cloud Message(HCM)""" 28 | 29 | JSON_ENCODER = _message_serializer.MessageSerializer() 30 | 31 | @classmethod 32 | def _send_to_server(cls, headers, body, url, verify_peer=False): 33 | try: 34 | msg_body = json.dumps(body) 35 | response = _http.post(url, msg_body, headers, verify_peer) 36 | 37 | if response.status_code is not 200: 38 | raise ApiCallError('http status code is {0} in send.'.format(response.status_code)) 39 | 40 | # json text to dict 41 | resp_dict = json.loads(response.text) 42 | return resp_dict 43 | 44 | except Exception as e: 45 | raise ApiCallError('caught exception when send. {0}'.format(e)) 46 | 47 | def __init__(self, appid_at, app_secret_at, appid_push, token_server='https://oauth-login.cloud.huawei.com/oauth2/v2/token', 48 | push_open_url='https://push-api.cloud.huawei.com'): 49 | """class init""" 50 | self.app_id_at = appid_at 51 | self.app_secret_at = app_secret_at 52 | if appid_push is None: 53 | self.appid_push = appid_at 54 | else: 55 | self.appid_push = appid_push 56 | self.token_expired_time = 0 57 | self.access_token = None 58 | self.token_server = token_server 59 | self.push_open_url = push_open_url 60 | self.hw_push_server = self.push_open_url + "/v1/{0}/messages:send" 61 | self.hw_push_topic_sub_server = self.push_open_url + "/v1/{0}/topic:subscribe" 62 | self.hw_push_topic_unsub_server = self.push_open_url + "/v1/{0}/topic:unsubscribe" 63 | self.hw_push_topic_query_server = self.push_open_url + "/v1/{0}/topic:list" 64 | 65 | def _refresh_token(self, verify_peer=False): 66 | """refresh access token 67 | :param verify_peer: (optional) Either a boolean, in which case it controls whether we verify 68 | the server's TLS certificate, or a string, in which case it must be a path 69 | to a CA bundle to use. Defaults to ``True``. 70 | """ 71 | headers = dict() 72 | headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=utf-8' 73 | 74 | params = dict() 75 | params['grant_type'] = 'client_credentials' 76 | params['client_secret'] = self.app_secret_at 77 | params['client_id'] = self.app_id_at 78 | 79 | msg_body = urllib.parse.urlencode(params) 80 | 81 | try: 82 | response = _http.post(self.token_server, msg_body, headers, verify_peer=verify_peer) 83 | 84 | if response.status_code is not 200: 85 | return False, 'http status code is {0} in get access token'.format(response.status_code) 86 | 87 | """ json string to directory """ 88 | response_body = json.loads(response.text) 89 | 90 | self.access_token = response_body.get('access_token') 91 | self.token_expired_time = int(round(time.time() * 1000)) + (int(response_body.get('expires_in')) - 5 * 60) * 1000 92 | 93 | return True, None 94 | except Exception as e: 95 | raise ApiCallError(format(repr(e))) 96 | 97 | def _is_token_expired(self): 98 | """is access token expired""" 99 | if self.access_token is None: 100 | """ need refresh token """ 101 | return True 102 | return int(round(time.time() * 1000)) >= self.token_expired_time 103 | 104 | def _update_token(self, verify_peer=False): 105 | """ 106 | :param verify_peer: (optional) Either a boolean, in which case it controls whether we verify 107 | the server's TLS certificate, or a string, in which case it must be a path 108 | to a CA bundle to use. Defaults to ``True``. 109 | :return: 110 | """ 111 | if self._is_token_expired() is True: 112 | result, reason = self._refresh_token(verify_peer) 113 | if result is False: 114 | raise ApiCallError(reason) 115 | 116 | def _create_header(self): 117 | headers = dict() 118 | headers['Content-Type'] = 'application/json;charset=utf-8' 119 | headers['Authorization'] = 'Bearer {0}'.format(self.access_token) 120 | return headers 121 | 122 | def send(self, message, validate_only, **kwargs): 123 | """ 124 | Sends the given message Huawei Cloud Messaging (HCM) 125 | :param message: JSON format message 126 | :param validate_only: validate message format or not 127 | :param kwargs: 128 | verify_peer: HTTPS server identity verification, use library 'certifi' 129 | :return: 130 | response dict: response body dict 131 | :raise: 132 | ApiCallError: failure reason 133 | """ 134 | verify_peer = kwargs['verify_peer'] 135 | self._update_token(verify_peer) 136 | headers = self._create_header() 137 | url = self.hw_push_server.format(self.appid_push) 138 | msg_body_dict = dict() 139 | msg_body_dict['validate_only'] = validate_only 140 | msg_body_dict['message'] = App.JSON_ENCODER.default(message) 141 | 142 | return App._send_to_server(headers, msg_body_dict, url, verify_peer) 143 | 144 | def subscribe_topic(self, topic, token_list): 145 | """ 146 | :param topic: The specific topic 147 | :param token_list: The token list to be added 148 | :return: 149 | """ 150 | self._update_token() 151 | headers = self._create_header() 152 | url = self.hw_push_topic_sub_server.format(self.appid_push) 153 | msg_body_dict = {'topic': topic, 'tokenArray': token_list} 154 | return App._send_to_server(headers, msg_body_dict, url) 155 | 156 | def unsubscribe_topic(self, topic, token_list): 157 | """ 158 | 159 | :param topic: The specific topic 160 | :param token_list: The token list to be deleted 161 | :return: 162 | """ 163 | self._update_token() 164 | headers = self._create_header() 165 | url = self.hw_push_topic_unsub_server.format(self.appid_push) 166 | msg_body_dict = {'topic': topic, 'tokenArray': token_list} 167 | return App._send_to_server(headers, msg_body_dict, url) 168 | 169 | def query_subscribe_list(self, token): 170 | """ 171 | :param token: The specific token 172 | :return: 173 | """ 174 | self._update_token() 175 | headers = self._create_header() 176 | url = self.hw_push_topic_query_server.format(self.appid_push) 177 | msg_body_dict = {'token': token} 178 | return App._send_to_server(headers, msg_body_dict, url) 179 | 180 | 181 | class ApiCallError(Exception): 182 | """Represents an Exception encountered while invoking the HCM API. 183 | 184 | Attributes: 185 | message: A error message string. 186 | detail: Original low-level exception. 187 | """ 188 | def __init__(self, message, detail=None): 189 | Exception.__init__(self, message) 190 | self.detail = detail 191 | -------------------------------------------------------------------------------- /hms/src/push_admin/_http.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import requests 18 | 19 | 20 | def post(url, req_body, headers=None, verify_peer=False): 21 | """ post http request to slb service 22 | :param url: url path 23 | :param req_body: http request body 24 | :param headers: http headers 25 | :param verify_peer: (optional) Either a boolean, in which case it controls whether we verify 26 | the server's TLS certificate, or a string, in which case it must be a path 27 | to a CA bundle to use. Defaults to ``True``. 28 | :return: 29 | success return response 30 | fali return None 31 | """ 32 | try: 33 | response = requests.post(url, data=req_body, headers=headers, timeout=10, verify=verify_peer) 34 | return response 35 | 36 | except Exception as e: 37 | raise ValueError('caught exception when post {0}. {1}'.format(url, e)) 38 | 39 | 40 | def _format_http_text(method, url, headers, body): 41 | """ 42 | print http head and body for request or response 43 | 44 | For examples: _format_http_text('', title, response.headers, response.text) 45 | """ 46 | result = method + ' ' + url + '\n' 47 | 48 | if headers is not None: 49 | for key, value in headers.items(): 50 | result = result + key + ': ' + value + '\n' 51 | 52 | result = result + body 53 | return result 54 | 55 | 56 | -------------------------------------------------------------------------------- /hms/src/push_admin/messaging.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from hms.src.push_admin import _messages, _app 18 | from hms.src import push_admin 19 | 20 | """HUAWEI Cloud Messaging module.""" 21 | 22 | """ General Data structure """ 23 | Message = _messages.Message 24 | Notification = _messages.Notification 25 | 26 | """ Web Push related data structure """ 27 | WebPushConfig = _messages.WebPushConfig 28 | WebPushHeader = _messages.WebPushHeader 29 | WebPushNotification = _messages.WebPushNotification 30 | WebPushNotificationAction = _messages.WebPushNotificationAction 31 | WebPushHMSOptions = _messages.WebPushHMSOptions 32 | 33 | """ Android Push related data structure """ 34 | AndroidConfig = _messages.AndroidConfig 35 | AndroidNotification = _messages.AndroidNotification 36 | AndroidClickAction = _messages.AndroidClickAction 37 | AndroidBadgeNotification = _messages.AndroidBadgeNotification 38 | AndroidLightSettings = _messages.AndroidLightSettings 39 | AndroidLightSettingsColor = _messages.AndroidLightSettingsColor 40 | 41 | """ APNS Push related data structure""" 42 | APNsConfig = _messages.APNsConfig 43 | APNsHeader = _messages.APNsHeader 44 | APNsPayload = _messages.APNsPayload 45 | APNsAps = _messages.APNsAps 46 | APNsAlert = _messages.APNsAlert 47 | APNsHMSOptions = _messages.APNsHMSOptions 48 | 49 | """Common exception definition""" 50 | ApiCallError = _app.ApiCallError 51 | 52 | 53 | def send_message(message, validate_only=False, app_id=None, verify_peer=False): 54 | """ 55 | Sends the given message Huawei Cloud Messaging (HCM) 56 | :param message: An instance of ``messaging.Message``. 57 | :param validate_only: A boolean indicating whether to run the operation in dry run mode (optional). 58 | :param app_id: app id parameters obtained by developer alliance applying for Push service (optional). 59 | :param verify_peer: (optional) Either a boolean, in which case it controls whether we verify 60 | the server's TLS certificate, or a string, in which case it must be a path 61 | to a CA bundle to use. Defaults to ``True``. 62 | :return: SendResponse 63 | Raises: 64 | ApiCallError: If an error occurs while sending the message to the HCM service. 65 | """ 66 | try: 67 | response = push_admin.get_app(app_id).send(message, validate_only, verify_peer=verify_peer) 68 | return SendResponse(response) 69 | except Exception as e: 70 | raise ApiCallError(repr(e)) 71 | 72 | 73 | def subscribe_topic(topic, token_list, app_id=None): 74 | """ 75 | :param topic: The specific topic 76 | :param token_list: The token list to be added 77 | :param app_id: application ID 78 | """ 79 | try: 80 | response = push_admin.get_app(app_id).subscribe_topic(topic, token_list) 81 | return TopicSubscribeResponse(response) 82 | except Exception as e: 83 | raise ApiCallError(repr(e)) 84 | 85 | 86 | def unsubscribe_topic(topic, token_list, app_id=None): 87 | """ 88 | :param topic: The specific topic 89 | :param token_list: The token list to be deleted 90 | :param app_id: application ID 91 | """ 92 | try: 93 | response = push_admin.get_app(app_id).unsubscribe_topic(topic, token_list) 94 | return TopicSubscribeResponse(response) 95 | except Exception as e: 96 | raise ApiCallError(repr(e)) 97 | 98 | 99 | def list_topics(token, app_id=None): 100 | """ 101 | :param token: The token to be queried 102 | :param app_id: application ID 103 | """ 104 | try: 105 | response = push_admin.get_app(app_id).query_subscribe_list(token) 106 | return TopicQueryResponse(response) 107 | except Exception as e: 108 | raise ApiCallError(repr(e)) 109 | 110 | 111 | class SendResponse(object): 112 | """ 113 | The response received from an send request to the HCM API. 114 | response: received http response body text from HCM. 115 | """ 116 | def __init__(self, response=None): 117 | try: 118 | self._code = response['code'] 119 | self._msg = response['msg'] 120 | self._requestId = response['requestId'] 121 | except Exception as e: 122 | raise ValueError(format(repr(e))) 123 | 124 | @property 125 | def code(self): 126 | """errcode""" 127 | return self._code 128 | 129 | @property 130 | def reason(self): 131 | """the description of errcode""" 132 | return self._msg 133 | 134 | @property 135 | def requestId(self): 136 | """A message ID string that uniquely identifies the message.""" 137 | return self._requestId 138 | 139 | 140 | class BaseTopicResponse(object): 141 | """ 142 | { 143 | "msg": "Success", 144 | "code": "80000000", 145 | "requestId": "157466304904000004000701" 146 | } 147 | """ 148 | def __init__(self, json_rsp=None): 149 | if json_rsp is None: 150 | self._msg = "" 151 | self._code = "" 152 | self._requestId = "" 153 | else: 154 | self._msg = json_rsp['msg'] 155 | self._code = json_rsp['code'] 156 | self._requestId = json_rsp['requestId'] 157 | 158 | @property 159 | def msg(self): 160 | return self._msg 161 | 162 | @property 163 | def code(self): 164 | return self._code 165 | 166 | @property 167 | def requestId(self): 168 | return self._requestId 169 | 170 | 171 | class TopicSubscribeResponse(BaseTopicResponse): 172 | """ 173 | { 174 | "msg": "Success", 175 | "code": "80000000", 176 | "requestId": "157466304904000004000701", 177 | "successCount": 2, 178 | "failureCount": 0, 179 | "errors": [] 180 | } 181 | """ 182 | def __init__(self, json_rsp=None): 183 | super(TopicSubscribeResponse, self).__init__(json_rsp=json_rsp) 184 | if json_rsp is None: 185 | self._successCount = 0 186 | self._failureCount = 0 187 | self._errors = [] 188 | else: 189 | self._successCount = json_rsp['successCount'] 190 | self._failureCount = json_rsp['failureCount'] 191 | self._errors = json_rsp['errors'] 192 | 193 | @property 194 | def successCount(self): 195 | return self._successCount 196 | 197 | @property 198 | def failureCount(self): 199 | return self._failureCount 200 | 201 | @property 202 | def errors(self): 203 | return self._errors 204 | 205 | 206 | class TopicQueryResponse(BaseTopicResponse): 207 | """ 208 | { 209 | "msg": "success", 210 | "code": "80000000", 211 | "requestId": "157466350121600008000701", 212 | "topics": [ 213 | { "name": "sports", 214 | "addDate": "2019-11-25" 215 | } ] 216 | } 217 | """ 218 | def __init__(self, json_rsp=None): 219 | super(TopicQueryResponse, self).__init__(json_rsp) 220 | self._topics = json_rsp['topics'] 221 | 222 | @property 223 | def topics(self): 224 | return self._topics 225 | -------------------------------------------------------------------------------- /hms/test/push_env.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | # Production ENV 19 | app_id = 'your aappId' 20 | app_secret = 'your appSecret' 21 | app_package_name = 'your packageName' 22 | token_server = 'https://oauth-login.cloud.huawei.com/oauth2/v2/token' 23 | push_open_api = 'https://push-api.cloud.huawei.com' 24 | 25 | # Production ENV 26 | app_id = 'your aappId' 27 | app_secret = 'your appSecret' 28 | app_package_name = 'your packageName' 29 | token_server = 'https://oauth-login.cloud.huawei.com/oauth2/v2/token' 30 | push_open_api = 'https://push-api.cloud.huawei.com' 31 | 32 | # Production EVN (instance APP) 33 | app_id = 'your aappId' 34 | app_secret = 'your appSecret' 35 | token_server = 'https://oauth-login.cloud.huawei.com/oauth2/v2/token' 36 | push_open_api = 'https://push-api.cloud.huawei.com' 37 | 38 | 39 | -------------------------------------------------------------------------------- /hms/test/send_apns_message.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from src import push_admin 18 | import json 19 | from src.push_admin import messaging 20 | 21 | 22 | headers = {messaging.APNsHeader.HEAD_APNs_ID: "6532dc0e-f581-7bfb-e1ab-60ec3cecea73"} 23 | 24 | apns_alert = messaging.APNsAlert(title="HMS Push Title", 25 | body="HMS Push Body", 26 | launch_image="Default.png", 27 | custom_data={"k1": "v1", "k2": "v2"}) 28 | 29 | apns_payload_aps = messaging.APNsAps(alert=apns_alert, 30 | badge=1, 31 | sound="wtewt.mp4", 32 | content_available=True, 33 | category="category", 34 | thread_id="id") 35 | 36 | payload = messaging.APNsPayload(aps=apns_payload_aps, 37 | acme_account="jane.appleseed@apple.com", 38 | acme_message="message123456") 39 | 40 | apns_hms_options = messaging.APNsHMSOptions(target_user_type=1) 41 | 42 | apns_push_config = messaging.APNsConfig(headers=headers, 43 | payload=payload, 44 | apns_hms_options=apns_hms_options) 45 | 46 | 47 | def send_apns_push_message(): 48 | """ 49 | a sample to show hwo to send web push message 50 | :return: 51 | """ 52 | message = messaging.Message( 53 | apns=apns_push_config, 54 | # TODO: 55 | token=['your token'] 56 | ) 57 | 58 | try: 59 | # Case 1: Local CA sample code 60 | # response = messaging.send_message(message, verify_peer="../Push-CA-Root.pem") 61 | # Case 2: No verification of HTTPS's certificate 62 | response = messaging.send_message(message) 63 | # Case 3: use certifi Library 64 | # import certifi 65 | # response = messaging.send_message(message, verify_peer=certifi.where()) 66 | print("response is ", json.dumps(vars(response))) 67 | assert (response.code == '80000000') 68 | except Exception as e: 69 | print(repr(e)) 70 | 71 | 72 | def init_app(): 73 | """init sdk app. The appID & app Secret use the Android's application ID and Secret under the same project, next version you can use 74 | the IOS application's own appId & secret! """ 75 | # TODO: 76 | app_id_at = "Your android application's app id" 77 | app_secret_at = "Your android application's app secret" 78 | app_id_push = "Your IOS application' app id " 79 | push_admin.initialize_app(app_id_at, app_secret_at, app_id_push) 80 | 81 | 82 | def main(): 83 | init_app() 84 | send_apns_push_message() 85 | 86 | 87 | if __name__ == '__main__': 88 | main() 89 | -------------------------------------------------------------------------------- /hms/test/send_condition_message.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from src import push_admin 18 | import json 19 | from src.push_admin import messaging 20 | 21 | 22 | notification = messaging.Notification( 23 | title='sample title', 24 | body='sample message body' 25 | ) 26 | 27 | android_notification = messaging.AndroidNotification( 28 | icon='/raw/ic_launcher2', 29 | color='#AACCDD', 30 | sound='/raw/shake', 31 | default_sound=True, 32 | tag='tagBoom', 33 | click_action=messaging.AndroidClickAction( 34 | action_type=1, 35 | intent="intent://com.huawei.codelabpush/deeplink?#Intent;scheme=pushscheme;launchFlags=0x4000000;i.age=180;S.name=abc;end"), 36 | body_loc_key='M.String.body', 37 | body_loc_args=('boy', 'dog'), 38 | title_loc_key='M.String.title', 39 | title_loc_args=["Girl", "Cat"], 40 | channel_id='Your Channel ID', 41 | notify_summary='some summary', 42 | multi_lang_key={"title_key": {"en": "value1"}, "body_key": {"en": "value2"}}, 43 | style=1, 44 | big_title='Big Boom Title(Topic)', 45 | big_body='Big Boom Body(Topic)', 46 | auto_clear=86400000, 47 | notify_id=486, 48 | group='Group1', 49 | importance=messaging.AndroidNotification.PRIORITY_HIGH, 50 | light_settings=messaging.AndroidLightSettings(color=messaging.AndroidLightSettingsColor( 51 | alpha=0, red=0, green=1, blue=1) 52 | , light_on_duration="3.5", light_off_duration="5S"), 53 | badge=messaging.AndroidBadgeNotification( 54 | add_num=1, clazz='Classic'), 55 | visibility=messaging.AndroidNotification.PUBLIC, 56 | foreground_show=True 57 | ) 58 | 59 | 60 | android = messaging.AndroidConfig( 61 | collapse_key=-1, 62 | urgency=messaging.AndroidConfig.HIGH_PRIORITY, 63 | ttl="10000s", 64 | bi_tag='the_sample_bi_tag_for_receipt_service', 65 | notification=android_notification, 66 | category=None 67 | ) 68 | 69 | 70 | def send_push_android_data_message(): 71 | """ 72 | a sample to show hwo to send web push message 73 | :return: 74 | """ 75 | message = messaging.Message( 76 | notification=notification, 77 | android=android, 78 | # TODO 79 | condition="'your topic' in topics" 80 | ) 81 | 82 | try: 83 | # Case 1: Local CA sample code 84 | # response = messaging.send_message(message, verify_peer="../Push-CA-Root.pem") 85 | # Case 2: No verification of HTTPS's certificate 86 | response = messaging.send_message(message) 87 | # Case 3: use certifi Library 88 | # import certifi 89 | # response = messaging.send_message(message, verify_peer=certifi.where()) 90 | print("response is ", json.dumps(vars(response))) 91 | assert (response.code == '80000000') 92 | except Exception as e: 93 | print(repr(e)) 94 | 95 | 96 | def init_app(): 97 | """init sdk app""" 98 | # TODO 99 | app_id = "Your android application's app id" 100 | app_secret = "Your android application's app secret" 101 | push_admin.initialize_app(app_id, app_secret) 102 | 103 | 104 | def main(): 105 | init_app() 106 | send_push_android_data_message() 107 | 108 | 109 | if __name__ == '__main__': 110 | main() 111 | -------------------------------------------------------------------------------- /hms/test/send_data_message.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from src import push_admin 18 | import json 19 | from src.push_admin import messaging 20 | 21 | 22 | """ 23 | [ANDROID] android 24 | """ 25 | android = messaging.AndroidConfig( 26 | collapse_key=-1, 27 | urgency=messaging.AndroidConfig.HIGH_PRIORITY, 28 | ttl="10000s", 29 | bi_tag='the_sample_bi_tag_for_receipt_service' 30 | ) 31 | 32 | 33 | def send_push_android_data_message(): 34 | """ 35 | a sample to show hwo to send web push message 36 | :return: 37 | """ 38 | message = messaging.Message( 39 | data="{'k1':'v1', 'k2':'v2'}", 40 | android=android, 41 | # TODO 42 | token=['your token'] 43 | ) 44 | 45 | try: 46 | # Case 1: Local CA sample code 47 | # response = messaging.send_message(message, verify_peer="../Push-CA-Root.pem") 48 | # Case 2: No verification of HTTPS's certificate 49 | response = messaging.send_message(message) 50 | # Case 3: use certifi Library 51 | # import certifi 52 | # response = messaging.send_message(message, verify_peer=certifi.where()) 53 | print("response is ", json.dumps(vars(response))) 54 | assert (response.code == '80000000') 55 | except Exception as e: 56 | print(repr(e)) 57 | 58 | 59 | def init_app(): 60 | """init sdk app""" 61 | # TODO 62 | app_id = "Your android application's app id" 63 | app_secret = "Your android application's app secret" 64 | push_admin.initialize_app(app_id, app_secret) 65 | 66 | 67 | def main(): 68 | init_app() 69 | send_push_android_data_message() 70 | 71 | 72 | if __name__ == '__main__': 73 | main() 74 | 75 | -------------------------------------------------------------------------------- /hms/test/send_instance_app_message.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import json 18 | 19 | from src import push_admin 20 | from src.push_admin import messaging 21 | 22 | android = messaging.AndroidConfig( 23 | collapse_key=-1, 24 | urgency=messaging.AndroidConfig.HIGH_PRIORITY, 25 | ttl="10000s", 26 | bi_tag='the_sample_bi_tag_for_receipt_service', 27 | fast_app_target=1, 28 | category=None 29 | ) 30 | 31 | 32 | def send_push_android_data_message(): 33 | """ 34 | a sample to show hwo to send web push message 35 | :return: 36 | """ 37 | message = messaging.Message( 38 | # English sample 39 | # data = "{\"pushtype\":0,\"pushbody\":{\"title\":\"Welcome to use Huawei HMS Push Kit?\",\"description\":\"One " 40 | # + "of the best push platform on the planet!!!\",\"page\":\"/\",\"params\":{\"key1\":\"test1\",\"key2\":\"test2\"},\"ringtone\":" 41 | # + "{\"vibration\":\"true\",\"breathLight\":\"true\"}}}", 42 | # Chinese sample 43 | data = "{\"pushtype\":0,\"pushbody\":{\"title\":\"欢迎使用华为HMS Push Kit!\",\"description\":\"世界上最好," 44 | + "最优秀的推送平台!!!\",\"page\":\"/\",\"params\":{\"key1\":\"test1\",\"key2\":\"test2\"},\"ringtone\":" 45 | + "{\"vibration\":\"true\",\"breathLight\":\"true\"}}}", 46 | android=android, 47 | # TODO 48 | token=['your token'] 49 | ) 50 | 51 | try: 52 | # Case 1: Local CA sample code 53 | # response = messaging.send_message(message, verify_peer="../Push-CA-Root.pem") 54 | # Case 2: No verification of HTTPS's certificate 55 | response = messaging.send_message(message) 56 | # Case 3: use certifi Library 57 | # import certifi 58 | # response = messaging.send_message(message, verify_peer=certifi.where()) 59 | print("response is ", json.dumps(vars(response))) 60 | assert (response.code == '80000000') 61 | except Exception as e: 62 | print(repr(e)) 63 | 64 | 65 | def init_app(): 66 | """init sdk app""" 67 | # TODO 68 | app_id = "Your instance application's (not android app) app id" 69 | app_secret = "Your instance application's (not android app) app secret" 70 | push_admin.initialize_app(app_id, app_secret) 71 | 72 | 73 | def main(): 74 | init_app() 75 | send_push_android_data_message() 76 | 77 | 78 | if __name__ == '__main__': 79 | main() 80 | -------------------------------------------------------------------------------- /hms/test/send_notifiy_message.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from src import push_admin 18 | import json 19 | from src.push_admin import messaging 20 | 21 | 22 | notification = messaging.Notification( 23 | title='sample title', 24 | body='sample message body', 25 | image='https://www.huawei.com/path/icon.png' 26 | ) 27 | 28 | android_notification = messaging.AndroidNotification( 29 | icon='/raw/ic_launcher2', 30 | color='#AACCDD', 31 | sound='/raw/shake', 32 | default_sound=True, 33 | tag='tagBoom', 34 | click_action=messaging.AndroidClickAction( 35 | action_type=1, 36 | intent="intent://com.huawei.codelabpush/deeplink?#Intent;scheme=pushscheme;launchFlags=0x4000000;i.age=180;S.name=abc;end"), 37 | body_loc_key='M.String.body', 38 | body_loc_args=('boy', 'dog'), 39 | title_loc_key='M.String.title', 40 | title_loc_args=["Girl", "Cat"], 41 | channel_id='Your Channel ID', 42 | notify_summary='some summary', 43 | multi_lang_key={"title_key": {"en": "value1"}, "body_key": {"en": "value2"}}, 44 | style=0, 45 | big_title='Big Boom Title', 46 | big_body='Big Boom Body', 47 | auto_clear=86400000, 48 | notify_id=4861, 49 | group='Group1', 50 | importance=messaging.AndroidNotification.PRIORITY_HIGH, 51 | light_settings=messaging.AndroidLightSettings(color=messaging.AndroidLightSettingsColor( 52 | alpha=0, red=0, green=1, blue=1) 53 | , light_on_duration="3.5", light_off_duration="5S"), 54 | badge=messaging.AndroidBadgeNotification( 55 | add_num=1, clazz='Classic'), 56 | visibility=messaging.AndroidNotification.PUBLIC, 57 | foreground_show=True 58 | ) 59 | 60 | 61 | android = messaging.AndroidConfig( 62 | collapse_key=-1, 63 | urgency=messaging.AndroidConfig.HIGH_PRIORITY, 64 | ttl="10000s", 65 | bi_tag='the_sample_bi_tag_for_receipt_service', 66 | notification=android_notification 67 | ) 68 | 69 | 70 | def send_push_android_notify_message(): 71 | """ 72 | a sample to show hwo to send web push message 73 | :return: 74 | """ 75 | message = messaging.Message( 76 | notification=notification, 77 | android=android, 78 | # TODO 79 | token=['Your Token'] 80 | ) 81 | 82 | try: 83 | # TODO 84 | # Case 1: Local CA sample code 85 | # response = messaging.send_message(message, verify_peer="../Push-CA-Root.pem") 86 | # Case 2: No verification of HTTPS's certificate 87 | response = messaging.send_message(message) 88 | # Case 3: use certifi Library 89 | # import certifi 90 | # response = messaging.send_message(message, verify_peer=certifi.where()) 91 | print("response is ", json.dumps(vars(response))) 92 | assert (response.code == '80000000') 93 | except Exception as e: 94 | print(repr(e)) 95 | 96 | 97 | def init_app(): 98 | """init sdk app""" 99 | # TODO 100 | app_id = "Your android application's app id" 101 | app_secret = "Your android application's app secret" 102 | push_admin.initialize_app(app_id, app_secret) 103 | 104 | 105 | def main(): 106 | init_app() 107 | send_push_android_notify_message() 108 | 109 | 110 | if __name__ == '__main__': 111 | main() 112 | -------------------------------------------------------------------------------- /hms/test/send_test_message.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from src import push_admin 18 | import json 19 | from src.push_admin import messaging 20 | 21 | 22 | notification = messaging.Notification( 23 | title='sample title', 24 | body='sample message body' 25 | ) 26 | 27 | android_notification = messaging.AndroidNotification( 28 | icon='/raw/ic_launcher2', 29 | color='#AACCDD', 30 | sound='/raw/shake', 31 | default_sound=True, 32 | tag='tagBoom', 33 | click_action=messaging.AndroidClickAction( 34 | action_type=1, 35 | intent="intent://com.huawei.codelabpush/deeplink?#Intent;scheme=pushscheme;launchFlags=0x4000000;i.age=180;S.name=abc;end"), 36 | body_loc_key='M.String.body', 37 | body_loc_args=('boy', 'dog'), 38 | title_loc_key='M.String.title', 39 | title_loc_args=["jack", "Cat"], 40 | channel_id='Your Channel ID', 41 | notify_summary='some summary', 42 | multi_lang_key={"title_key": {"en": "value1"}, "body_key": {"en": "value2"}}, 43 | style=1, 44 | big_title='Big Boom Title', 45 | big_body='Big Boom Body', 46 | auto_clear=86400000, 47 | group='Group1', 48 | importance=messaging.AndroidNotification.PRIORITY_HIGH, 49 | light_settings=messaging.AndroidLightSettings(color=messaging.AndroidLightSettingsColor( 50 | alpha=0, red=0, green=1, blue=1) 51 | , light_on_duration="3.5", light_off_duration="5S"), 52 | badge=messaging.AndroidBadgeNotification( 53 | add_num=1, clazz='Classic'), 54 | visibility=messaging.AndroidNotification.PUBLIC, 55 | foreground_show=True 56 | ) 57 | 58 | 59 | android = messaging.AndroidConfig( 60 | collapse_key=-1, 61 | urgency=messaging.AndroidConfig.HIGH_PRIORITY, 62 | ttl="10000s", 63 | bi_tag='the_sample_bi_tag_for_receipt_service', 64 | notification=android_notification, 65 | category=None 66 | ) 67 | 68 | 69 | def send_push_android_data_message(): 70 | """ 71 | a sample to show hwo to send web push message 72 | :return: 73 | """ 74 | message = messaging.Message( 75 | notification=notification, 76 | android=android, 77 | # TODO 78 | token=['Your Token'] 79 | ) 80 | 81 | try: 82 | # Case 1: Local CA sample code 83 | # response = messaging.send_message(message, validate_only=True, verify_peer="../Push-CA-Root.pem") 84 | # Case 2: No verification of HTTPS's certificate 85 | response = messaging.send_message(message, validate_only=True) 86 | # Case 3: use certifi Library 87 | # import certifi 88 | # response = messaging.send_message(message, validate_only=True, verify_peer=certifi.where()) 89 | print("response is ", json.dumps(vars(response))) 90 | assert (response.code == '80000000') 91 | except Exception as e: 92 | print(repr(e)) 93 | 94 | 95 | def init_app(): 96 | """init sdk app""" 97 | # TODO 98 | app_id = "Your android application's app id" 99 | app_secret = "Your android application's app secret" 100 | push_admin.initialize_app(app_id, app_secret) 101 | 102 | 103 | def main(): 104 | init_app() 105 | send_push_android_data_message() 106 | 107 | 108 | if __name__ == '__main__': 109 | main() 110 | -------------------------------------------------------------------------------- /hms/test/send_topic_message.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import json 18 | 19 | from src.push_admin import initialize_app 20 | from src.push_admin import messaging 21 | 22 | notification = messaging.Notification( 23 | title='sample title', 24 | body='sample message body' 25 | ) 26 | 27 | android_notification = messaging.AndroidNotification( 28 | icon='/raw/ic_launcher2', 29 | color='#AACCDD', 30 | sound='/raw/shake', 31 | default_sound=True, 32 | tag='tagBoom', 33 | click_action=messaging.AndroidClickAction( 34 | action_type=1, 35 | intent="intent://com.huawei.codelabpush/deeplink?#Intent;scheme=pushscheme;launchFlags=0x4000000;i.age=180;S.name=abc;end"), 36 | body_loc_key='M.String.body', 37 | body_loc_args=('boy', 'dog'), 38 | title_loc_key='M.String.title', 39 | title_loc_args=["Girl", "Cat"], 40 | channel_id='Your Channel ID', 41 | notify_summary='some summary', 42 | multi_lang_key={"title_key": {"en": "value1"}, "body_key": {"en": "value2"}}, 43 | style=1, 44 | big_title='Big Boom Title', 45 | big_body='Big Boom Body', 46 | auto_clear=86400000, 47 | notify_id=486, 48 | group='Group1', 49 | importance=messaging.AndroidNotification.PRIORITY_HIGH, 50 | light_settings=messaging.AndroidLightSettings(color=messaging.AndroidLightSettingsColor( 51 | alpha=0, red=0, green=1, blue=1) 52 | , light_on_duration="3.5", light_off_duration="5S"), 53 | badge=messaging.AndroidBadgeNotification( 54 | add_num=1, clazz='Classic'), 55 | visibility=messaging.AndroidNotification.PUBLIC, 56 | foreground_show=True 57 | ) 58 | 59 | 60 | android = messaging.AndroidConfig( 61 | collapse_key=-1, 62 | urgency=messaging.AndroidConfig.HIGH_PRIORITY, 63 | ttl="10000s", 64 | bi_tag='the_sample_bi_tag_for_receipt_service', 65 | notification=android_notification, 66 | category=None 67 | ) 68 | 69 | 70 | def send_push_android_data_message(): 71 | """ 72 | a sample to show hwo to send web push message 73 | :return: 74 | """ 75 | message = messaging.Message( 76 | notification=notification, 77 | android=android, 78 | # TODO 79 | topic='Your topic' 80 | ) 81 | 82 | try: 83 | # Case 1: Local CA sample code 84 | # response = messaging.send_message(message, verify_peer="../Push-CA-Root.pem") 85 | # Case 2: No verification of HTTPS's certificate 86 | response = messaging.send_message(message) 87 | # Case 3: use certifi Library 88 | # import certifi 89 | # response = messaging.send_message(message, verify_peer=certifi.where()) 90 | print("response is ", json.dumps(vars(response))) 91 | assert (response.code == '80000000') 92 | except Exception as e: 93 | print(repr(e)) 94 | 95 | 96 | def init_app(): 97 | """init sdk app""" 98 | # TODO 99 | app_id = "Your android application's app id" 100 | app_secret = "Your android application's app secret" 101 | initialize_app(app_id, app_secret) 102 | 103 | 104 | def main(): 105 | init_app() 106 | send_push_android_data_message() 107 | 108 | 109 | if __name__ == '__main__': 110 | main() 111 | -------------------------------------------------------------------------------- /hms/test/send_webpush_message.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | # 3 | # Copyright 2020. Huawei Technologies Co., Ltd. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import json 18 | from src import push_admin 19 | from src.push_admin import messaging 20 | 21 | web_push_headers = messaging.WebPushHeader(ttl="100") 22 | 23 | web_push_notification = messaging.WebPushNotification( 24 | title="中文推送", 25 | body="中文推送内容中文推送内容中文推送内容中文推送内容中文推送内容中文推送内容中文推送内容中文推送内容中文推送内容", 26 | icon="https://developer-portalres-drcn.dbankcdn.com/system/modules/org.opencms.portal.template.core/\ 27 | resources/images/icon_Promotion.png", 28 | actions=[messaging.WebPushNotificationAction(action="click", title="title", icon="https://developer-portalres-drcn.\ 29 | dbankcdn.com/system/modules/org.opencms.portal.template.core/resources/images/icon_Promotion.png")], 30 | badge="badge", 31 | data="data", 32 | dir="auto", 33 | image="image url", 34 | lang="en", 35 | renotify=False, 36 | require_interaction=False, 37 | silent=True, 38 | tag="tag", 39 | timestamp=32323, 40 | vibrate=[1, 2, 3]) 41 | 42 | web_push_config = messaging.WebPushConfig(headers=web_push_headers, notification=web_push_notification) 43 | 44 | 45 | def send_push_android_data_message(): 46 | """ 47 | a sample to show hwo to send web push message 48 | :return: 49 | """ 50 | message = messaging.Message( 51 | web_push=web_push_config, 52 | # TODO 53 | token=['your token'] 54 | ) 55 | 56 | try: 57 | # Case 1: Local CA sample code 58 | # response = messaging.send_message(message, verify_peer="../Push-CA-Root.pem") 59 | # Case 2: No verification of HTTPS's certificate 60 | response = messaging.send_message(message) 61 | # Case 3: use certifi Library 62 | # import certifi 63 | # response = messaging.send_message(message, verify_peer=certifi.where()) 64 | print("response is ", json.dumps(vars(response))) 65 | assert (response.code == '80000000') 66 | except Exception as e: 67 | print(repr(e)) 68 | 69 | 70 | def init_app(): 71 | """init sdk app 72 | The appID & app Secret use the Android's application ID and Secret under the same project, next version you can use 73 | the web application's own appId & secret! 74 | """ 75 | # TODO 76 | app_id_at = "Your android application's app id" 77 | app_secret_at = "Your android application's app secret" 78 | app_id_push = "Your Web application' app id " 79 | push_admin.initialize_app(app_id_at, app_secret_at, app_id_push) 80 | 81 | 82 | def main(): 83 | init_app() 84 | send_push_android_data_message() 85 | 86 | 87 | if __name__ == '__main__': 88 | main() 89 | -------------------------------------------------------------------------------- /make-x25519-key.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import sys 4 | from os.path import isfile 5 | from nacl.public import PrivateKey 6 | 7 | if len(sys.argv) != 2 or not sys.argv[1] or sys.argv[1].startswith("-"): 8 | print( 9 | f"Usage: {sys.argv[0]} FILENAME -- generates a random x25519 key and writes it to FILENAME" 10 | ) 11 | sys.exit(1) 12 | 13 | filename = sys.argv[1] 14 | 15 | if isfile(filename): 16 | print(f"Refusing to overwrite existing file {filename}") 17 | sys.exit(2) 18 | 19 | x = PrivateKey.generate() 20 | with open(filename, "w") as f: 21 | print(x.encode().hex(), file=f) 22 | 23 | print(f"Generated new private key in {filename}; corresponding pubkey is:", end='\n\t') 24 | print(x.public_key.encode().hex()) 25 | -------------------------------------------------------------------------------- /spns.ini.example: -------------------------------------------------------------------------------- 1 | [db] 2 | 3 | # The postgresql database URL 4 | url = postgresql:///spns 5 | 6 | [log] 7 | 8 | # Log level 9 | level = INFO 10 | 11 | [hivemind] 12 | 13 | # The socket we listen on, that other parts of the PN service use to communicate with the central "hivemind" process 14 | listen = ipc://./hivemind.sock 15 | 16 | # Optional encrypted TCP curve listener 17 | #listen_curve = tcp://0.0.0.0:22030 18 | 19 | # One or more admin x25519 pubkeys (comma-separated) for the listen_curve address; you can specify 20 | # multiple by separating by whitespace or commas. Any listed pubkeys will have admin access when 21 | # connecting to the listen_curve address. 22 | listen_curve_admin = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef 23 | 24 | # OMQ address where we can make RPC requests to oxend. This can be a local oxend (e.g. 25 | # ipc:///path/to/oxend.sock), a plaintext address, or a curve-encrypted address with pubkey. 26 | oxend_rpc = tcp://localhost:22029 27 | 28 | # How often (in seconds) the main process re-checks existing subscriptions (for push renewals, expiries, etc.) 29 | subs_interval = 10 30 | 31 | # How many SN connections we attempt to establish at once. Can be large to make a huge burst of 32 | # connections at startup, or lower to pace those connections a little more. 33 | max_connects = 1000 34 | 35 | # We use a time-based filter on notifications to avoid sending duplicate notifications; this timer 36 | # controls the minimum time for which we will filter duplicates. (In practice, the filter will 37 | # often last somewhat longer than this, but this is the minimum). 38 | filter_lifetime = 300 39 | 40 | # How many separate oxenmq instances to start to talk to network service nodes. Increasing this can 41 | # be helpful if a single oxenmq instance (typically the proxy thread) starts bottlenecking under 42 | # heavy load. If this is set to 1 or greater then this many extra servers are started and each 43 | # connection to a remote service node is assigned in round-robin order across the instances, while 44 | # the main local oxenmq instance will be used only for non-push requests (subscriptions, timers, 45 | # communication with notifiers, local admin stats endpoints, etc.). If unset or set to 0 then just 46 | # one oxenmq instance will be used for everything (both local and push traffic). 47 | #omq_push_instances = 4 48 | 49 | # How long the main hivemind process will wait at startup before establishing connections to 50 | # the network's service nodes. This delay is designed to allow subordinate notification processes 51 | # to connect to the hivemind to ensure that notification services are ready after a restart before 52 | # we subscribe (and start receiving) message notifications. 53 | startup_wait = 8.0 54 | 55 | # Comma-separate list of expected notifier names; if non-empty then while starting up, the 56 | # `startup_wait` timer becomes a maximum wait time: we start up as soon as the timer is reached *or* 57 | # we have received notifier registrations for all the notifiers listed here. (Note that even when 58 | # this is set other notifiers can still register, they just aren't required for startup to proceed). 59 | #notifiers_expected = apns,firebase 60 | 61 | 62 | [keys] 63 | 64 | # This section lists the files containing keys needed by the PN server. Each file is the 32-bytes 65 | # private key, either in binary or as a single 64-character hex string. 66 | # 67 | # You can generate these X25519 keys using: 68 | # 69 | # ./make-x25519-key.py FILENAME 70 | # 71 | 72 | hivemind = key_x25519 73 | 74 | onionreq = onionreq_x25519 75 | 76 | 77 | [notify-firebase] 78 | 79 | # Magic json token file google spits out from somewhere deep in the firebase admin control panel 80 | #token_file = loki-a1a1a-firebase-adminsdk-blahblah-1234567890.json 81 | 82 | # How many times we will attempt to re-send notification on failure 83 | retries = 3 84 | 85 | # Interval (seconds) between retry attempts 86 | retry_interval = 10 87 | 88 | # How frequently (in seconds) we send notification requests 89 | notify_interval = 0.1 90 | 91 | [notify-apns] 92 | # Application identifier, aka "topic". Required when using apns. 93 | identifier = com.loki-project.loki-messenger 94 | 95 | # Filename containing the APNS client certificate. Required when using apns. 96 | cert_file = apns-cert.pem 97 | 98 | # How many times we will attempt to re-send notification on failure 99 | retries = 2 100 | 101 | # Interval (seconds) between retry attempts 102 | retry_interval = 3 103 | 104 | 105 | [notify-apns-sandbox] 106 | # Application identifier, aka "topic". Required when using apns. 107 | identifier = com.loki-project.loki-messenger 108 | 109 | # Filename containing the APNS client certificate. Required when using apns. 110 | cert_file = apns-sandbox-cert.pem 111 | 112 | # How many times we will attempt to re-send notification on failure 113 | retries = 0 114 | 115 | # Interval (seconds) between retry attempts 116 | retry_interval = 3 117 | -------------------------------------------------------------------------------- /spns/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.0.0" 2 | -------------------------------------------------------------------------------- /spns/blake2b.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include "bytes.hpp" 11 | #include "utils.hpp" 12 | 13 | namespace spns { 14 | 15 | namespace detail { 16 | template && (sizeof(T) <= 8), int> = 0> 17 | void blake2b_update(crypto_generichash_blake2b_state& s, const T& val) { 18 | char buf[20]; 19 | auto [end, ec] = std::to_chars(std::begin(buf), std::end(buf), val); 20 | assert(ec == std::errc()); 21 | crypto_generichash_blake2b_update( 22 | &s, reinterpret_cast(buf), end - buf); 23 | } 24 | 25 | template = 0> 26 | void blake2b_update( 27 | crypto_generichash_blake2b_state& s, const std::basic_string_view& val) { 28 | crypto_generichash_blake2b_update( 29 | &s, reinterpret_cast(val.data()), val.size()); 30 | } 31 | 32 | template = 0> 33 | void blake2b_update(crypto_generichash_blake2b_state& s, const std::basic_string& val) { 34 | crypto_generichash_blake2b_update( 35 | &s, reinterpret_cast(val.data()), val.size()); 36 | } 37 | 38 | template , int> = 0> 39 | void blake2b_update(crypto_generichash_blake2b_state& s, const T& val) { 40 | crypto_generichash_blake2b_update( 41 | &s, reinterpret_cast(val.data()), val.SIZE); 42 | } 43 | } // namespace detail 44 | 45 | template , int> = 0> 46 | void blake2b_keyed(Hash& result, ustring_view key, const T&... args) { 47 | crypto_generichash_blake2b_state s; 48 | crypto_generichash_blake2b_init(&s, key.data(), key.size(), result.SIZE); 49 | (detail::blake2b_update(s, args), ...); 50 | crypto_generichash_blake2b_final(&s, result, result.SIZE); 51 | } 52 | 53 | template , int> = 0> 54 | Hash blake2b_keyed(ustring_view key, const T&... args) { 55 | Hash result; 56 | blake2b_keyed(result, key, args...); 57 | return result; 58 | } 59 | 60 | template 61 | Hash blake2b_keyed(std::string_view key, const T&... args) { 62 | return blake2b_keyed(as_usv(key), args...); 63 | } 64 | 65 | template 66 | Hash blake2b_keyed(bstring_view key, const T&... args) { 67 | return blake2b_keyed(as_usv(key), args...); 68 | } 69 | 70 | template 71 | Hash blake2b(const T&... args) { 72 | return blake2b_keyed(""sv, args...); 73 | } 74 | 75 | } // namespace spns 76 | -------------------------------------------------------------------------------- /spns/bytes.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | 15 | namespace spns { 16 | 17 | template 18 | struct bytes : std::array { 19 | static constexpr size_t SIZE = N; 20 | 21 | using std::array::data; 22 | std::basic_string_view view() const { return {data(), SIZE}; } 23 | std::string_view sv() const { return {reinterpret_cast(data()), SIZE}; } 24 | std::basic_string_view usv() const { 25 | return {reinterpret_cast(data()), SIZE}; 26 | } 27 | 28 | std::string hex() const { return oxenc::to_hex(this->begin(), this->end()); } 29 | 30 | // Implicit conversion to unsigned char* for easier passing into libsodium functions 31 | template >> 32 | constexpr operator T() noexcept { 33 | return reinterpret_cast(this->data()); 34 | } 35 | template >> 36 | constexpr operator T() const noexcept { 37 | return reinterpret_cast(this->data()); 38 | } 39 | }; 40 | 41 | struct is_bytes_impl { 42 | template 43 | static std::true_type check(bytes*); 44 | static std::false_type check(...); 45 | }; 46 | 47 | template 48 | inline constexpr bool is_bytes = decltype(is_bytes_impl::check(static_cast(nullptr)))::value; 49 | 50 | struct AccountID : bytes<33> {}; 51 | struct Ed25519PK : bytes<32> {}; 52 | struct X25519PK : bytes<32> {}; 53 | struct X25519SK : bytes<32> {}; 54 | struct SubaccountTag : bytes<36> {}; 55 | struct Signature : bytes<64> {}; 56 | struct EncKey : bytes<32> {}; 57 | 58 | struct Blake2B_32 : bytes<32> {}; 59 | 60 | struct Subaccount { 61 | SubaccountTag tag; /// Provided by the account owner 62 | Signature sig; /// signature of tag, signed by account owner 63 | 64 | // Returns true if two subaccounts have the same tag 65 | bool is_same(const Subaccount& other) const { return tag == other.tag; } 66 | 67 | // Returns true if two optional subaccounts refer to the same subaccount or account (i.e. both 68 | // empty, or both set to the same subaccount tag). Does not require identical signatures. 69 | static bool is_same(const std::optional& a, const std::optional& b) { 70 | if (a.has_value() != b.has_value()) 71 | return false; // One set, one unset 72 | if (!a.has_value()) 73 | return false; // Both unset 74 | return a->is_same(*b); 75 | } 76 | }; 77 | 78 | template , int> = 0> 79 | inline std::basic_string_view as_bsv(const T& v) { 80 | return {reinterpret_cast(v.data()), T::SIZE}; 81 | } 82 | 83 | template , int> = 0> 84 | inline std::basic_string_view as_usv(const T& v) { 85 | return {reinterpret_cast(v.data()), v.size()}; 86 | } 87 | 88 | // std::hash-implementing class that "hashes" by just reading the size_t-size bytes starting at the 89 | // 16th byte. 90 | template && (T::SIZE >= 32)>> 91 | struct bytes_simple_hasher { 92 | size_t operator()(const T& x) const { 93 | size_t hash; 94 | std::memcpy(&hash, x.data() + 16, sizeof(hash)); 95 | return hash; 96 | } 97 | }; 98 | 99 | template >> 100 | void from_hex_or_b64(T& val, std::string_view input) { 101 | if (input.size() == T::SIZE) { 102 | std::memcpy(val.data(), input.data(), T::SIZE); 103 | return; 104 | } 105 | if (input.size() == 2 * T::SIZE && oxenc::is_hex(input)) { 106 | oxenc::from_hex(input.begin(), input.end(), val.begin()); 107 | return; 108 | } 109 | while (!input.empty() && input.back() == '=') 110 | input.remove_suffix(1); 111 | if (input.size() == oxenc::to_base64_size(T::SIZE, false) && oxenc::is_base64(input)) { 112 | oxenc::from_base64(input.begin(), input.end(), val.begin()); 113 | return; 114 | } 115 | 116 | throw std::invalid_argument{"Invalid value: expected bytes, hex, or base64"}; 117 | } 118 | 119 | template >> 120 | T from_hex_or_b64(std::string_view input) { 121 | T val; 122 | from_hex_or_b64(val, input); 123 | return val; 124 | } 125 | 126 | } // namespace spns 127 | 128 | namespace std { 129 | 130 | template <> 131 | struct hash : spns::bytes_simple_hasher {}; 132 | template <> 133 | struct hash : spns::bytes_simple_hasher {}; 134 | template <> 135 | struct hash : spns::bytes_simple_hasher {}; 136 | template <> 137 | struct hash : spns::bytes_simple_hasher {}; 138 | 139 | } // namespace std 140 | 141 | namespace fmt { 142 | 143 | template 144 | struct formatter>> : fmt::formatter { 145 | template 146 | auto format(const T& val, FormatContext& ctx) const { 147 | return formatter::format(val.hex(), ctx); 148 | } 149 | }; 150 | 151 | } // namespace fmt 152 | -------------------------------------------------------------------------------- /spns/config.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #include "bytes.hpp" 12 | 13 | namespace spns { 14 | 15 | using namespace std::literals; 16 | 17 | struct Config { 18 | oxenmq::address oxend_rpc; 19 | 20 | std::string pg_connect = "postgresql:///spns"; 21 | 22 | // Local listening admin socket 23 | std::string hivemind_sock = "ipc://./hivemind.sock"; 24 | // Optional curve-enabled listening socket 25 | std::optional hivemind_curve; 26 | // list of x25519 client pubkeys who shall be treated as admins on the hivemind_curve socket 27 | std::unordered_set hivemind_curve_admin; 28 | 29 | // The main hivemind omq listening keypair. Must be set explicitly. 30 | X25519PK pubkey; 31 | X25519SK privkey; 32 | 33 | std::chrono::seconds filter_lifetime = 10min; 34 | 35 | // How long after startup we wait for notifier services to register themselves with us before we 36 | // connect to the network and start processing user requests. 37 | std::chrono::milliseconds notifier_wait = 10s; 38 | 39 | // If non-empty then we stop waiting (i.e. before `notifier_wait`) for new notifiers once we 40 | // have a registered notifier for all of the services in this set. 41 | std::unordered_set notifiers_expected; 42 | 43 | // How often we recheck for re-subscriptions for push renewals, expiries, etc. 44 | std::chrono::seconds subs_interval = 30s; 45 | 46 | // Number of extra oxenmq instances to start up for push notifications. If 0 then no extra ones 47 | // are started and just the main oxenmq instance is used for everything. The extra instances 48 | // are used exlusively for push notifications; each connection to a new SN is round-robin 49 | // assigned across the instances. 50 | int omq_push_instances = 0; 51 | 52 | // Maximum connections we will attempt to establish simultaneously (we can have more, we just 53 | // won't try to open more than this at once until some succeed or fail). You can set this to 0 54 | // for a "dry run" mode where no connections at all will be made. 55 | int max_pending_connects = 500; 56 | }; 57 | 58 | } // namespace spns 59 | -------------------------------------------------------------------------------- /spns/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import re 4 | import logging 5 | import coloredlogs 6 | from nacl.public import PrivateKey 7 | from spns.core import Config, logger as core_logger 8 | import oxenmq 9 | 10 | logger = logging.getLogger("spns") 11 | 12 | # Set up colored logging; we come back to set the level once we know it 13 | coloredlogs.install(milliseconds=True, isatty=True, logger=logger) 14 | 15 | # Global config; we set values in here, then pass it to HiveMind during startup. 16 | config = Config() 17 | 18 | # Keypairs; the "hivemind" key in here gets set in `config` for the main hivemind instance; 19 | # "onionreq" is the main onionreq keypair; other keys can be set as well (e.g. for notifiers). 20 | PRIVKEYS = {} 21 | PUBKEYS = {} 22 | 23 | # We stash anything in a `[notify-xyz]` into `NOTIFY['xyz']` for notifiers to piggyback on the 24 | # config. 25 | NOTIFY = {} 26 | 27 | # Will be true if we're running as a uwsgi app, false otherwise; used where we need to do things 28 | # only in one case or another (e.g. database initialization only via app mode). 29 | RUNNING_AS_APP = False 30 | try: 31 | import uwsgi # noqa: F401 32 | 33 | RUNNING_AS_APP = True 34 | except ImportError: 35 | pass 36 | 37 | 38 | truthy = ("y", "yes", "Y", "Yes", "true", "True", "on", "On", "1") 39 | falsey = ("n", "no", "N", "No", "false", "False", "off", "Off", "0") 40 | booly = truthy + falsey 41 | 42 | 43 | def looks_true(val): 44 | """Returns True if val is a common true value, False if a common false value, None if neither.""" 45 | if val in truthy: 46 | return True 47 | if val in falsey: 48 | return False 49 | return None 50 | 51 | 52 | def load_config(): 53 | if "SPNS_CONFIG" in os.environ: 54 | conf_ini = os.environ["SPNS_CONFIG"] 55 | if conf_ini and not os.path.exists(conf_ini): 56 | raise RuntimeError(f"SPNS_CONFIG={conf_ini} specified, but path does not exist!") 57 | else: 58 | conf_ini = "spns.ini" 59 | if not os.path.exists(conf_ini): 60 | raise RuntimeError( 61 | "spns.ini does not exist; either create it or use SPNS_CONFIG=... to specify an" 62 | " alternate config file" 63 | ) 64 | 65 | if not conf_ini: 66 | return 67 | 68 | logger.info(f"Loading config from {conf_ini}") 69 | cp = configparser.ConfigParser() 70 | cp.read(conf_ini) 71 | 72 | # Set log level up first so that it's here for the rest of the settings 73 | if "log" in cp.sections() and "level" in cp["log"]: 74 | coloredlogs.install(level=cp["log"]["level"], logger=logger) 75 | 76 | def path_exists(path): 77 | return not path or os.path.exists(path) 78 | 79 | def val_or_none(v): 80 | return v or None 81 | 82 | def days_to_seconds(v): 83 | return float(v) * 86400.0 84 | 85 | def days_to_seconds_or_none(v): 86 | return days_to_seconds(v) if v else None 87 | 88 | def set_of_strs(v): 89 | return {s for s in re.split("[,\\s]+", v) if s != ""} 90 | 91 | def bool_opt(name): 92 | return (name, lambda x: x in booly, lambda x: x in truthy) 93 | 94 | # Map of: section => { param => ('config_property', test lambda, value lambda) } 95 | # global is the string name of the global variable to set 96 | # test lambda returns True/False for validation (if None/omitted, accept anything) 97 | # value lambda extracts the value (if None/omitted use str value as-is) 98 | setting_map = { 99 | "db": {"url": ("pg_connect", lambda x: x.startswith("postgresql"))}, 100 | # 'keys': ... special handling ... 101 | "hivemind": { 102 | "subs_interval": ("subs_interval", None, int), 103 | "max_connects": ("max_pending_connects", None, int), 104 | "filter_lifetime": ("filter_lifetime", None, int), 105 | "omq_push_instances": ("omq_push_instances", None, int), 106 | "startup_wait": ("notifier_wait", None, lambda x: round(1000 * float(x))), 107 | "notifiers_expected": ( 108 | "notifiers_expected", 109 | None, 110 | lambda x: set(z for z in (y.strip() for y in x.split(",")) if z), 111 | ), 112 | "listen": ("hivemind_sock", lambda x: re.search("^(?:tcp|ipc)://.", x)), 113 | "listen_curve": ("hivemind_curve", lambda x: re.search("^tcp://.", x)), 114 | "listen_curve_admin": ( 115 | "hivemind_curve_admin", 116 | lambda x: re.search("^(?:[a-fA-F0-9]{64}\s+)*[a-fA-F0-9]{64}\s*$", x), 117 | lambda x: set(bytes.fromhex(y) for y in x.split() if y), 118 | ), 119 | "oxend_rpc": ("oxend_rpc", lambda x: re.search("^(?:tcp|ipc|curve)://.", x)), 120 | }, 121 | } 122 | 123 | def parse_option(fields, s, opt): 124 | if opt not in fields: 125 | logger.warning(f"Ignoring unknown config setting [{s}].{opt} in {conf_ini}") 126 | return 127 | conf = fields[opt] 128 | value = cp[s][opt] 129 | 130 | assert isinstance(conf, tuple) and 1 <= len(conf) <= 3 131 | global config 132 | assert hasattr(config, conf[0]) 133 | 134 | if len(conf) >= 2 and conf[1]: 135 | if not conf[1](value): 136 | raise RuntimeError(f"Invalid value [{s}].{opt}={value} in {conf_ini}") 137 | 138 | if len(conf) >= 3 and conf[2]: 139 | value = conf[2](value) 140 | 141 | logger.debug(f"Set config.{conf[0]} = {value}") 142 | setattr(config, conf[0], value) 143 | 144 | for s in cp.sections(): 145 | if s == "keys": 146 | for opt in cp["keys"]: 147 | filename = cp["keys"][opt] 148 | with open(filename, "rb") as f: 149 | keybytes = f.read() 150 | if len(keybytes) == 32: 151 | privkey = PrivateKey(keybytes) 152 | else: 153 | # Assume hex-encoded 154 | keyhex = keybytes.decode().strip() 155 | if len(keyhex) != 64: 156 | raise RuntimeError( 157 | f"Could not read '{filename}' for option [keys]{opt}: invalid file size" 158 | ) 159 | if any(x not in "0123456789abcdefABCDEF" for x in keyhex): 160 | raise RuntimeError( 161 | f"Could not read '{filename}' for option [keys]{opt}: expected bytes or hex" 162 | ) 163 | 164 | privkey = PrivateKey(bytes.fromhex(keyhex)) 165 | PRIVKEYS[opt] = privkey 166 | PUBKEYS[opt] = privkey.public_key 167 | 168 | logger.info( 169 | f"Loaded {opt} X25519 keypair with pubkey {PUBKEYS[opt].encode().hex()}" 170 | ) 171 | elif s == "log": 172 | for opt in cp["log"]: 173 | if opt == "level": 174 | core_logger.set_level(cp["log"][opt]) 175 | elif opt.startswith("level-") and len(opt) > 6: 176 | core_logger.set_level(opt[6:], cp["log"][opt]) 177 | else: 178 | logger.warning(f"Ignoring unknown log item [log] {opt} in {conf_ini}") 179 | 180 | elif s.startswith("notify-"): 181 | for opt in cp[s]: 182 | NOTIFY.setdefault(s[7:], {})[opt] = cp[s][opt] 183 | 184 | elif s in setting_map: 185 | for opt in cp[s]: 186 | parse_option(setting_map[s], s, opt) 187 | 188 | else: 189 | logger.warning(f"Ignoring unknown section [{s}] in {conf_ini}") 190 | 191 | config.privkey = PRIVKEYS["hivemind"].encode() 192 | config.pubkey = PUBKEYS["hivemind"].encode() 193 | 194 | 195 | try: 196 | load_config() 197 | except Exception as e: 198 | logger.critical(f"Failed to load config: {e}") 199 | raise 200 | -------------------------------------------------------------------------------- /spns/hive/signature.cpp: -------------------------------------------------------------------------------- 1 | #include "signature.hpp" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include "../blake2b.hpp" 9 | 10 | namespace spns::hive { 11 | 12 | void verify_signature(std::string_view sig_msg, const Signature& sig, const Ed25519PK& pubkey, std::string_view descr) { 13 | if (0 != crypto_sign_verify_detached(sig, as_usv(sig_msg).data(), sig_msg.size(), pubkey)) 14 | throw signature_verify_failure{std::string{descr} + " verification failed"}; 15 | } 16 | 17 | namespace { 18 | constexpr std::byte SUBACC_FLAG_READ{0b0001}; 19 | constexpr std::byte SUBACC_FLAG_ANY_PREFIX{0b1000}; 20 | } 21 | 22 | /// Throws signature_verify_failure on signature failure. 23 | void verify_storage_signature( 24 | std::string_view sig_msg, 25 | const Signature& sig, 26 | const SwarmPubkey& pubkey, 27 | const std::optional& subaccount) { 28 | 29 | if (subaccount) { 30 | // Parse the subaccount tag: 31 | // prefix aka netid (05 for session ids, 03 for groups): 32 | auto prefix = subaccount->tag[0]; 33 | // read/write/etc. flags: 34 | auto flags = subaccount->tag[1]; 35 | 36 | // If you don't have the read bit we can't help you: 37 | if ((flags & SUBACC_FLAG_READ) == std::byte{0}) 38 | throw signature_verify_failure{"Invalid subaccount: this subaccount does not have read permission"}; 39 | 40 | // Unless the subaccount has the "any prefix" flag, check that the prefix matches the 41 | // account prefix: 42 | if ((flags & SUBACC_FLAG_ANY_PREFIX) == std::byte{0} && 43 | prefix != pubkey.id[0]) 44 | throw signature_verify_failure{"Invalid subaccount: subaccount and main account have mismatched network prefix"}; 45 | 46 | // Verify that the main account has signed the subaccount tag: 47 | verify_signature(subaccount->tag.sv(), subaccount->sig, pubkey.ed25519, "Subaccount auth signature"); 48 | 49 | // the subaccount pubkey (starts at [4]; [2] and [3] are future use/null padding): 50 | Ed25519PK sub_pk; 51 | std::memcpy(sub_pk.data(), &subaccount->tag[4], 32); 52 | 53 | // Verify that the subaccount pubkey signed this message (and thus is allowed, transitively, 54 | // since the main account signed the subaccount): 55 | verify_signature(sig_msg, sig, sub_pk, "Subaccount main signature"); 56 | 57 | } else { 58 | verify_signature(sig_msg, sig, pubkey.ed25519); 59 | } 60 | } 61 | 62 | } // namespace spns::hive 63 | -------------------------------------------------------------------------------- /spns/hive/signature.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include "../bytes.hpp" 6 | #include "../utils.hpp" 7 | #include "../swarmpubkey.hpp" 8 | 9 | namespace spns::hive { 10 | 11 | using namespace std::literals; 12 | 13 | class signature_verify_failure : public std::runtime_error { 14 | using std::runtime_error::runtime_error; 15 | }; 16 | 17 | /// Verifies that the given signature is a valid signature for `sig_msg`. Supports regular 18 | /// ed25519_pubkey signatures as well as oxen-storage-server delegated subaccount signatures (if 19 | /// `subaccount` is given). 20 | 21 | // Plain jane Ed25519 signature verification. Throws a `signature_verify_failure` on verification 22 | // failure. 23 | void verify_signature(std::string_view sig_msg, const Signature& sig, const Ed25519PK& pubkey, std::string_view descr = "Signature"sv); 24 | 25 | /// Throws signature_verify_failure on signature failure. 26 | void verify_storage_signature( 27 | std::string_view sig_msg, 28 | const Signature& sig, 29 | const SwarmPubkey& pubkey, 30 | const std::optional& subaccount); 31 | 32 | } // namespace spns::hive 33 | -------------------------------------------------------------------------------- /spns/hive/snode.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | #include 19 | 20 | #include "subscription.hpp" 21 | 22 | namespace spns { 23 | class HiveMind; 24 | } 25 | 26 | namespace spns::hive { 27 | 28 | using namespace std::literals; 29 | 30 | // Maximum size of simultaneous subscriptions in a single subscription request; if we overflow then 31 | // any stragglers wait until the next request, delaying them by a few seconds. (This is not a rock 32 | // hard limit: we estimate slightly and stop as soon as we exceed it, which means we can go over it 33 | // a bit after appending the last record). 34 | inline constexpr size_t SUBS_REQUEST_LIMIT = 5'000'000; 35 | 36 | // How long (in seconds) after a successful subscription before we re-subscribe; each subscription 37 | // gets a uniform random value between these two values (to spread out the renewal requests a bit). 38 | inline constexpr std::chrono::seconds RESUBSCRIBE_MIN = 45min; 39 | inline constexpr std::chrono::seconds RESUBSCRIBE_MAX = 55min; 40 | 41 | // How long we wait (in seconds) after a connection failure to a snode storage server before 42 | // re-trying the connection; we use the first value after the first failure, the second one after 43 | // the second failure, and so on (if we run off the end we use the last value). 44 | inline constexpr std::array CONNECT_COOLDOWN = {10s, 30s, 60s, 120s}; 45 | 46 | template >> 47 | inline std::string_view as_sv(const T& data) { 48 | return {reinterpret_cast(data.data()), T::SIZE}; 49 | } 50 | 51 | class SNode { 52 | // Class managing a connection to a single service node 53 | 54 | HiveMind& hivemind_; 55 | oxenmq::OxenMQ& omq_; 56 | oxenmq::ConnectionID conn_; 57 | oxenmq::address addr_; 58 | std::atomic connected_ = false; 59 | std::unordered_set subs_; 60 | uint64_t swarm_; 61 | 62 | std::mutex mutex_; // Mutex for our local stuff; we must *never* do something with hivemind 63 | // that requires a (HiveMind) lock while we hold this. 64 | 65 | using system_clock = std::chrono::system_clock; 66 | using steady_clock = std::chrono::steady_clock; 67 | using system_time = system_clock::time_point; 68 | using steady_time = steady_clock::time_point; 69 | inline static constexpr system_time system_epoch{}; 70 | 71 | // Sorted by next re-subscription time. We reset the pubkey as a means of lazy deferred queue 72 | // entry deletion (when processing the queue, we just skip such entries). 73 | std::deque, system_time>> next_; 74 | 75 | std::optional cooldown_until_; 76 | int cooldown_fails_ = 0; 77 | 78 | public: 79 | const uint64_t& swarm{swarm_}; 80 | 81 | SNode(HiveMind& hivemind, oxenmq::OxenMQ& omq, oxenmq::address addr, uint64_t swarm); 82 | 83 | ~SNode() { disconnect(); } 84 | 85 | /// Checks the given address against the current one: if different, it gets replaced, the 86 | /// current connection (if any) is disconnected, and then we initiate reconnection to the new 87 | /// address. 88 | /// 89 | /// Does nothing if already connected to the given address. 90 | void connect(oxenmq::address addr); 91 | 92 | /// Initiates a connection, if not already connected, to the current address. 93 | void connect(); 94 | 95 | bool connected() { return connected_; } 96 | 97 | void disconnect(); 98 | 99 | void on_connected(oxenmq::ConnectionID c); 100 | 101 | void on_connect_fail(oxenmq::ConnectionID c, std::string_view reason); 102 | 103 | /// Adds a new account to be signed up for subscriptions, if it is not already subscribed. 104 | /// The new account's subscription will be submitted to the SS the next time check_subs() is 105 | /// called (either automatically or manually). 106 | /// 107 | /// If `force_now` is True then the account is scheduled for subscription at the next update 108 | /// even if already exists. 109 | void add_account(const SwarmPubkey& account, bool force_now = false); 110 | 111 | /// Called when this snode's swarm changes; all current subscriptions are dropped. 112 | void reset_swarm(uint64_t new_swarm); 113 | 114 | /// Called when the network swarm list has changed to eject any swarm subscriptions that don't 115 | /// belong here anymore. Any existing subscribers that are no longer in this swarm will be 116 | /// removed. (Even without a swarm change of this node, this can happen if another new swarm is 117 | /// created next to us). 118 | /// 119 | /// This isn't responsible for adding *new* swarm members: this is just called as a first step 120 | /// for removing any that shouldn't be here anymore. 121 | void remove_stale_swarm_members(const std::vector& swarm_ids); 122 | 123 | /// Check our subscriptions to resubscribe to any that need it. Takes a reference to hivemind's 124 | /// master list of all subscriptions (to be able to pull subscription details from). 125 | /// 126 | /// If initial_subs is true then this is the initial request and we fire off a batch of 127 | /// subscriptions and then another batch upon reply, etc. until there are no more subs to send; 128 | /// otherwise we fire off just up to SUBS_LIMIT re-subscriptions. 129 | /// 130 | /// If `fast` is true then we only look for and process unix-epoch leading elements, which are 131 | /// the ones we put on we a brand new subscription comes in. 132 | /// 133 | /// This method is *only* called from HiveMind. 134 | void check_subs( 135 | const std::unordered_map>& subs, 136 | bool initial_subs = false, 137 | bool fast = false); 138 | }; 139 | 140 | } // namespace spns::hive 141 | -------------------------------------------------------------------------------- /spns/hive/subscription.cpp: -------------------------------------------------------------------------------- 1 | #include "subscription.hpp" 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include 18 | 19 | #include "signature.hpp" 20 | 21 | namespace spns::hive { 22 | 23 | template 24 | static void append_int(std::string& s, Int val) { 25 | char sig_ts_buf[20]; 26 | auto [end, ec] = std::to_chars(std::begin(sig_ts_buf), std::end(sig_ts_buf), val); 27 | s.append(sig_ts_buf, end - sig_ts_buf); 28 | } 29 | 30 | Subscription::Subscription( 31 | const SwarmPubkey& pubkey, 32 | std::optional subaccount_, 33 | std::vector namespaces_, 34 | bool want_data_, 35 | int64_t sig_ts_, 36 | Signature sig_, 37 | bool _skip_validation) : 38 | 39 | subaccount{std::move(subaccount_)}, 40 | namespaces{std::move(namespaces_)}, 41 | want_data{want_data_}, 42 | sig_ts{sig_ts_}, 43 | sig{std::move(sig_)} { 44 | 45 | if (namespaces.empty()) 46 | throw std::invalid_argument{"Subscription: namespaces missing or empty"}; 47 | 48 | for (size_t i = 0; i < namespaces.size() - 1; i++) { 49 | if (namespaces[i] > namespaces[i + 1]) 50 | throw std::invalid_argument{"Subscription: namespaces are not sorted numerically"}; 51 | if (namespaces[i] == namespaces[i + 1]) 52 | throw std::invalid_argument{"Subscription: namespaces contains duplicates"}; 53 | } 54 | 55 | if (!sig_ts) 56 | throw std::invalid_argument{"Subscription: signature timestamp is missing"}; 57 | auto now = std::chrono::duration_cast( 58 | std::chrono::system_clock::now().time_since_epoch()) 59 | .count(); 60 | if (sig_ts <= now - 14 * 24 * 60 * 60) 61 | throw std::invalid_argument{"Subscription: sig_ts timestamp is too old"}; 62 | if (sig_ts >= now + 24 * 60 * 60) 63 | throw std::invalid_argument{"Subscription: sig_ts timestamp is too far in the future"}; 64 | 65 | if (!_skip_validation) { 66 | std::string sig_msg; 67 | sig_msg.reserve(7 + 66 + 10 + 1 + 7 * namespaces.size() - 1); 68 | sig_msg += "MONITOR"; 69 | oxenc::to_hex(pubkey.id.begin(), pubkey.id.end(), std::back_inserter(sig_msg)); 70 | append_int(sig_msg, sig_ts); 71 | sig_msg += want_data ? '1' : '0'; 72 | for (size_t i = 0; i < namespaces.size(); i++) { 73 | if (i > 0) 74 | sig_msg += ','; 75 | append_int(sig_msg, namespaces[i]); 76 | } 77 | verify_storage_signature(sig_msg, sig, pubkey, subaccount); 78 | } 79 | } 80 | 81 | bool Subscription::covers(const Subscription& other) const { 82 | if (!Subaccount::is_same(subaccount, other.subaccount)) 83 | return false; 84 | if (other.want_data && !want_data) 85 | return false; 86 | 87 | // Namespaces are sorted, so we can walk through sequentially, comparing heads, and 88 | // skipping any extras we have have in self. We fail by either running out of self 89 | // namespaces before consuming all the other namespaces (which means other has some 90 | // greater than self's maximum), or when the head of self is greater than the head of 91 | // other (which means self is missing some at the beginning or in the middle). 92 | for (size_t i = 0, j = 0; j < other.namespaces.size(); i++) { 93 | if (i >= namespaces.size()) 94 | // Ran out of self namespaces before we consumed all the other namespaces 95 | return false; 96 | if (namespaces[i] > other.namespaces[j]) 97 | // Head of the self is greater, so we are missing (at least) one of other's 98 | return false; 99 | if (namespaces[i] == other.namespaces[j]) 100 | // Equal, so we have it: advance j (as well as i) so that both heads advance 101 | j++; 102 | // Otherwise [i] < [j], so just skip `i` but leave `j` alone 103 | } 104 | 105 | return true; 106 | } 107 | 108 | } // namespace spns::hive 109 | -------------------------------------------------------------------------------- /spns/hive/subscription.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | #include "../bytes.hpp" 15 | #include "../swarmpubkey.hpp" 16 | 17 | namespace spns::hive { 18 | 19 | using namespace std::literals; 20 | 21 | enum class SUBSCRIBE : int { 22 | OK = 0, // Great Success! 23 | BAD_INPUT = 1, // Unparseable, invalid values, missing required arguments, etc. (details in the 24 | // string) 25 | SERVICE_NOT_AVAILABLE = 2, // The requested service name isn't currently available 26 | SERVICE_TIMEOUT = 3, // The backend service did not response 27 | ERROR = 4, // There was some other error processing the subscription (details in the string) 28 | INTERNAL_ERROR = 5, // An internal program error occured processing the request 29 | 30 | _END, // Not a proper value; allows easier compile-time checks against new values 31 | }; 32 | 33 | class subscribe_error : public std::runtime_error { 34 | public: 35 | SUBSCRIBE code; 36 | subscribe_error(SUBSCRIBE code, std::string message) : 37 | std::runtime_error{message}, code{code} {} 38 | 39 | int numeric_code() const { return static_cast(code); } 40 | }; 41 | 42 | struct Subscription { 43 | static constexpr std::chrono::seconds SIGNATURE_EXPIRY{14 * 24h}; 44 | 45 | std::optional subaccount; 46 | std::vector namespaces; 47 | bool want_data; 48 | int64_t sig_ts; 49 | Signature sig; 50 | 51 | Subscription( 52 | const SwarmPubkey& pubkey_, 53 | std::optional subaccout_, 54 | std::vector namespaces_, 55 | bool want_data_, 56 | int64_t sig_ts_, 57 | Signature sig_, 58 | bool _skip_validation = false); 59 | 60 | // Returns true if `this` and `other` represent the same subscription as far as upstream swarm 61 | // subscription is concerned. That is: same subaccount tag, same namespaces, and same want_data 62 | // value. The caller is responsible for also ensuring that the subscription applies to the same 63 | // account (i.e. has the same SwarmPubkey). 64 | bool is_same(const Subscription& other) const { 65 | return is_same(other.subaccount, other.namespaces, other.want_data); 66 | } 67 | // Same as above, but takes the constituent parts. 68 | bool is_same( 69 | const std::optional& o_subaccount, 70 | const std::vector& o_namespaces, 71 | bool o_want_data) const { 72 | return Subaccount::is_same(subaccount, o_subaccount) && namespaces == o_namespaces && 73 | want_data == o_want_data; 74 | } 75 | 76 | // Returns true if `this` subscribes to at least everything needed for `other`; `this` can 77 | // return extra things (e.g. extra namespaces), but cannot omit anything that `other` needs to 78 | // send notifications, nor can the two subscriptions use different subaccount tags. This is 79 | // *only* valid for two Subscriptions referring to the same account! 80 | bool covers(const Subscription& other) const; 81 | 82 | bool is_expired(int64_t now) const { return sig_ts < now - SIGNATURE_EXPIRY.count(); } 83 | 84 | bool is_newer(const Subscription& other) const { return sig_ts > other.sig_ts; } 85 | }; 86 | 87 | } // namespace spns::hive 88 | -------------------------------------------------------------------------------- /spns/hivemind.py: -------------------------------------------------------------------------------- 1 | from .core import HiveMind, logger as core_logger 2 | from . import config 3 | from .config import logger 4 | import time 5 | import signal 6 | import os 7 | 8 | 9 | def run(): 10 | """Runs a HiveMind instance indefinitely""" 11 | 12 | hivemind = None 13 | 14 | def stop(*args): 15 | nonlocal hivemind 16 | if hivemind: 17 | logger.info("Shutting down hivemind") 18 | hivemind.stop() 19 | logger.info("Hivemind stopped") 20 | hivemind = None 21 | 22 | def sig_die(signum, frame): 23 | raise OSError(f"Caught signal {signal.Signals(signum).name}") 24 | 25 | try: 26 | logger.info("Starting hivemind") 27 | core_logger.start("stderr") 28 | hivemind = HiveMind(config.config) 29 | logger.info("Hivemind started") 30 | 31 | signal.signal(signal.SIGHUP, sig_die) 32 | signal.signal(signal.SIGINT, sig_die) 33 | 34 | while True: 35 | time.sleep(3600) 36 | except Exception as e: 37 | logger.critical(f"HiveMind died: {e}") 38 | 39 | if hivemind: 40 | stop() 41 | 42 | 43 | if __name__ == "__main__": 44 | run() 45 | -------------------------------------------------------------------------------- /spns/notifiers/apns_sandbox.py: -------------------------------------------------------------------------------- 1 | # 2 | # Apple Push Notification service (sandbox mode) 3 | # 4 | # See apns.py 5 | # 6 | 7 | from .apns import APNSHandler, run 8 | 9 | if __name__ == "__main__": 10 | run(startup_delay=0, sandbox=True) 11 | -------------------------------------------------------------------------------- /spns/notifiers/dummy.py: -------------------------------------------------------------------------------- 1 | from oxenmq import OxenMQ, AuthLevel, Message, Address 2 | import oxenc 3 | from nacl.hash import blake2b as blake2b_oneshot 4 | from nacl.encoding import HexEncoder 5 | from threading import Lock 6 | import json 7 | import traceback 8 | import time 9 | import signal 10 | import systemd.daemon 11 | from .util import derive_notifier_key, warn_on_except 12 | from .. import config 13 | from ..config import logger 14 | from ..core import SUBSCRIBE 15 | from datetime import timedelta 16 | 17 | omq = None 18 | hivemind = None 19 | 20 | stats_lock = Lock() 21 | notifies = 0 # Total successful notifications 22 | failures = 0 # Failed notifications (i.e. neither first attempt nor retries worked) 23 | total_notifies = 0 24 | total_failures = 0 25 | 26 | 27 | @warn_on_except 28 | def validate(msg: Message): 29 | parts = msg.data() 30 | if len(parts) != 2 or parts[0] != b"dummy": 31 | logger.warning("Internal error: invalid input to notifier.validate") 32 | msg.reply(str(SUBSCRIBE.ERROR.value), "Internal error") 33 | return 34 | 35 | try: 36 | data = json.loads(parts[1]) 37 | 38 | # We can validate/require whatever data we want: 39 | foo = data["foo"] 40 | if not foo or not foo.startswith("TEST-"): 41 | raise ValueError("Invalid input: foo must start with TEST-") 42 | bar = data["bar"] 43 | if not isinstance(bar, int): 44 | raise ValueError("Invalid input: bar must be an integer") 45 | 46 | # This could just be some magic device id provided directly, or could be some id we can 47 | # deterministically generate, e.g. with a hash like this: 48 | unique_id = blake2b_oneshot( 49 | f"{bar}_{foo}".encode(), digest_size=48, key=b"TestNotifier", encoder=HexEncoder 50 | ) 51 | 52 | msg.reply("0", unique_id, oxenc.bt_serialize({"foo": foo, "bar": bar})) 53 | except KeyError as e: 54 | msg.reply(str(SUBSCRIBE.BAD_INPUT.value), f"Error: missing required service_info key {e}") 55 | except Exception as e: 56 | msg.reply(str(SUBSCRIBE.ERROR.value), str(e)) 57 | 58 | 59 | @warn_on_except 60 | def push_notification(msg: Message): 61 | data = oxenc.bt_deserialize(msg.data()[0]) 62 | logger.critical( 63 | f"Dummy notifier received push for {data[b'&']}, enc_key {data[b'^']}, message hash {data[b'#']} ({len(data[b'~'])}B) for account {data[b'@'].hex()}/{data[b'n']}" 64 | ) 65 | global stats_lock, notifies 66 | with stats_lock: 67 | notifies += 1 68 | 69 | 70 | @warn_on_except 71 | def ping(): 72 | global omq, hivemind, stats_lock, notifies, failures, total_notifies, total_failures 73 | with stats_lock: 74 | report = {"+notifies": notifies, "+failures": failures} 75 | total_notifies += notifies 76 | total_failures += failures 77 | notifies, failures = 0, 0 78 | 79 | logger.debug(f"dummy re-registering and reporting stats {report}") 80 | omq.send(hivemind, "admin.register_service", "dummy") 81 | omq.send(hivemind, "admin.service_stats", "dummy", oxenc.bt_serialize(report)) 82 | systemd.daemon.notify( 83 | f"WATCHDOG=1\nSTATUS=Running; {total_notifies} notifications, {total_failures} failures" 84 | ) 85 | 86 | 87 | def connect_to_hivemind(): 88 | # These do not and *should* not match hivemind or any other notifier: that is, each notifier 89 | # needs its own unique keypair. We do, however, want it to persist so that we can 90 | # restart/reconnect and receive messages sent while we where restarting. 91 | key = derive_notifier_key("dummy") 92 | 93 | global omq, hivemind 94 | 95 | omq = OxenMQ(pubkey=key.public_key.encode(), privkey=key.encode()) 96 | 97 | cat = omq.add_category("notifier", AuthLevel.basic) 98 | cat.add_request_command("validate", validate) 99 | cat.add_command("push", push_notification) 100 | 101 | omq.add_timer(ping, timedelta(seconds=5)) 102 | 103 | omq.start() 104 | 105 | hivemind = omq.connect_remote( 106 | Address(config.config.hivemind_sock), auth_level=AuthLevel.basic, ephemeral_routing_id=False 107 | ) 108 | 109 | omq.send(hivemind, "admin.register_service", "dummy") 110 | 111 | 112 | def disconnect(): 113 | global omq, hivemind 114 | if omq: 115 | omq.disconnect(hivemind) 116 | hivemind = None 117 | omq = None 118 | 119 | 120 | def run(startup_delay=4.0): 121 | """Run the dummy notifier, forever""" 122 | 123 | def sig_die(signum, frame): 124 | raise OSError(f"Caught signal {signal.Signals(signum).name}") 125 | 126 | if startup_delay > 0: 127 | time.sleep(startup_delay) 128 | 129 | try: 130 | systemd.daemon.notify("STATUS=Initializing dummy notifier...") 131 | logger.info("dummy test notifier starting up") 132 | connect_to_hivemind() 133 | logger.info("dummy test notifier connected to hivemind") 134 | 135 | signal.signal(signal.SIGHUP, sig_die) 136 | signal.signal(signal.SIGINT, sig_die) 137 | 138 | systemd.daemon.notify("READY=1\nSTATUS=Started") 139 | 140 | while True: 141 | time.sleep(1) 142 | 143 | except Exception: 144 | logger.error(f"dummy test notifier mule died via exception:\n{traceback.format_exc()}") 145 | 146 | 147 | if __name__ == "__main__": 148 | run(startup_delay=0) 149 | -------------------------------------------------------------------------------- /spns/notifiers/fcm.py: -------------------------------------------------------------------------------- 1 | # Google Firebase push notification server, using aiofcm for higher performance than the stock 2 | # bloated Google Firebase Python API. 3 | # 4 | 5 | from aiofcm import FCM, Message, PRIORITY_HIGH 6 | import asyncio 7 | 8 | from .. import config 9 | from ..config import logger 10 | from ..core import SUBSCRIBE 11 | from .util import encrypt_payload, warn_on_except 12 | 13 | from oxenc import bt_serialize, bt_deserialize, to_base64 14 | 15 | omq = None 16 | hivemind = None 17 | loop = None 18 | fcm = None 19 | 20 | # Whenever we add/change fields we increment this so that a Session client could figure out what it 21 | # is speaking to: 22 | SPNS_FCM_VERSION = 1 23 | 24 | # If our JSON payload hits 4000 bytes then Google will reject it, so we limit ourselves to this size 25 | # *before* encryption + base64 encoding. If the source message exceeds this, we send an alternative 26 | # "too big" response in the metadata instead of including the message. 27 | MAX_MSG_SIZE = 2500 28 | 29 | 30 | @warn_on_except 31 | def validate(msg: Message): 32 | parts = msg.data() 33 | if len(parts) != 2 or parts[0] != b"firebase": 34 | logger.warning("Internal error: invalid input to notifier.validate") 35 | msg.reply(str(SUBSCRIBE.ERROR.value), "Internal error") 36 | return 37 | 38 | try: 39 | data = json.loads(parts[1]) 40 | 41 | # We require just the device token, passed as `token`: 42 | token = data["token"] 43 | if not token: 44 | raise ValueError(f"Invalid firebase device token") 45 | msg.reply("0", token) 46 | except KeyError as e: 47 | msg.reply(str(SUBSCRIBE.BAD_INPUT.value), f"Error: missing required key {e}") 48 | except Exception as e: 49 | msg.reply(str(SUBSCRIBE.ERROR.value), str(e)) 50 | 51 | 52 | def make_notifier(msg: Message): 53 | async def fcm_notify(): 54 | global fcm 55 | max_retries = config.NOTIFY["firebase"].get("retries", 0) 56 | retry_sleep = config.NOTIFY["firebase"].get("retry_interval", 10) 57 | retries = max_retries 58 | while True: 59 | response = await fcm.send_message(msg) 60 | if response.is_successful: 61 | return 62 | if retries > 0: 63 | retries -= 1 64 | await asyncio.sleep(retry_sleep) 65 | else: 66 | logger.warning( 67 | f"Failed to send notification: {response.status} ({response.description}); giving up after {max_retries} retries" 68 | ) 69 | 70 | return fcm_notify 71 | 72 | 73 | @warn_on_except 74 | def push_notification(msg: Message): 75 | data = bt_deserialize(msg[0]) 76 | 77 | enc_payload = encrypt_notify_payload(data, max_msg_size=MAX_MSG_SIZE) 78 | 79 | device_token = data["&"] # unique service id, as we returned from validate 80 | 81 | msg = Message( 82 | device_token=device_token, data={"enc_payload": enc_payload}, priority=PRIORITY_HIGH 83 | ) 84 | 85 | global loop 86 | asyncio.run_coroutine_threadsafe(make_notifier(msg), loop) 87 | 88 | 89 | def run(): 90 | """Runs the asyncio event loop, forever.""" 91 | 92 | # These do not and *should* not match hivemind or any other notifier: that is, each notifier 93 | # needs its own unique keypair. We do, however, want it to persist so that we can 94 | # restart/reconnect and receive messages sent while we where restarting. 95 | key = derive_notifier_key(__name__) 96 | 97 | global omq, hivemind, firebase 98 | 99 | omq = OxenMQ(pubkey=key.public_key.encode(), privkey=key.encode()) 100 | 101 | cat = omq.add_category("notifier", AuthLevel.basic) 102 | cat.add_request_command("validate", validate) 103 | cat.add_command("push", push_notification) 104 | 105 | omq.start() 106 | 107 | hivemind = omq.connect_remote( 108 | Address(config.config.hivemind_sock), auth_level=AuthLevel.basic, ephemeral_routing_id=False 109 | ) 110 | 111 | conf = config.NOTIFY["firebase"] 112 | fcm = FCM() # FIXME? 113 | 114 | omq.send(hivemind, "admin.register_service", "firebase") 115 | 116 | try: 117 | loop.run_forever() 118 | finally: 119 | loop.close() 120 | 121 | omq.disconnect(hivemind) 122 | hivemind = None 123 | omq = None 124 | loop = None 125 | -------------------------------------------------------------------------------- /spns/notifiers/firebase.py: -------------------------------------------------------------------------------- 1 | # Google Firebase push notification server 2 | 3 | from .. import config 4 | from ..config import logger 5 | from ..core import SUBSCRIBE 6 | from .util import encrypt_notify_payload, derive_notifier_key, warn_on_except, NotifyStats 7 | 8 | from pyfcm import FCMNotification 9 | 10 | import oxenc 11 | from oxenmq import OxenMQ, Message, Address, AuthLevel 12 | 13 | import datetime 14 | import time 15 | import json 16 | import signal 17 | import systemd.daemon 18 | from threading import Lock 19 | 20 | omq = None 21 | hivemind = None 22 | firebase_app = None 23 | 24 | notify_queue = [] 25 | queue_lock = Lock() 26 | queue_timer = None 27 | 28 | # Whenever we add/change fields we increment this so that a Session client could figure out what it 29 | # is speaking to: 30 | SPNS_FIREBASE_VERSION = 1 31 | 32 | # If our JSON payload hits 4000 bytes then Google will reject it, so we limit ourselves to this size 33 | # *before* encryption + base64 encoding. If the source message exceeds this, we send an alternative 34 | # "too big" response in the metadata instead of including the message. 35 | MAX_MSG_SIZE = 2500 36 | 37 | # Firebase max simultaneous notifications: 38 | MAX_NOTIFIES = 500 39 | 40 | 41 | stats = NotifyStats() 42 | 43 | 44 | @warn_on_except 45 | def validate(msg: Message): 46 | parts = msg.data() 47 | if len(parts) != 2 or parts[0] != b"firebase": 48 | logger.warning("Internal error: invalid input to notifier.validate") 49 | msg.reply(str(SUBSCRIBE.ERROR.value), "Internal error") 50 | return 51 | 52 | try: 53 | data = json.loads(parts[1]) 54 | 55 | # We require just the device token, passed as `token`: 56 | token = data["token"] 57 | if not token: 58 | raise ValueError(f"Invalid firebase device token") 59 | msg.reply("0", token) 60 | except KeyError as e: 61 | msg.reply(str(SUBSCRIBE.BAD_INPUT.value), f"Error: missing required key {e}") 62 | except Exception as e: 63 | msg.reply(str(SUBSCRIBE.ERROR.value), str(e)) 64 | 65 | 66 | @warn_on_except 67 | def push_notification(msg: Message): 68 | data = oxenc.bt_deserialize(msg.data()[0]) 69 | 70 | enc_payload = encrypt_notify_payload(data, max_msg_size=MAX_MSG_SIZE) 71 | 72 | device_token = data[b"&"].decode() # unique service id, as we returned from validate 73 | 74 | msg = { 75 | 'fcm_token': device_token, 76 | 'data_payload': { 77 | "enc_payload": oxenc.to_base64(enc_payload), 78 | "spns": f"{SPNS_FIREBASE_VERSION}" 79 | } 80 | } 81 | 82 | global notify_queue, queue_lock 83 | with queue_lock: 84 | notify_queue.append(msg) 85 | 86 | 87 | @warn_on_except 88 | def send_pending(): 89 | global notify_queue, queue_lock, firebase_app, stats 90 | with queue_lock: 91 | queue, notify_queue = notify_queue, [] 92 | 93 | i = 0 94 | while i < len(queue): 95 | results = firebase_app.async_notify_multiple_devices(params_list=queue[i : i + MAX_NOTIFIES]) 96 | with stats.lock: 97 | stats.notifies += min(len(queue) - i, MAX_NOTIFIES) 98 | 99 | # FIXME: process/reschedule failures? 100 | 101 | i += MAX_NOTIFIES 102 | 103 | 104 | @warn_on_except 105 | def ping(): 106 | """Makes sure we are registered and reports updated stats to hivemind; called every few seconds""" 107 | global omq, hivemind, stats 108 | omq.send(hivemind, "admin.register_service", "firebase") 109 | omq.send(hivemind, "admin.service_stats", "firebase", oxenc.bt_serialize(stats.collect())) 110 | systemd.daemon.notify( 111 | f"WATCHDOG=1\nSTATUS=Running; {stats.total_notifies} notifications, " 112 | f"{stats.total_retries} retries, {stats.total_failures} failures" 113 | ) 114 | 115 | 116 | def start(): 117 | """Starts up the firebase listener.""" 118 | 119 | # These do not and *should* not match hivemind or any other notifier: that is, each notifier 120 | # needs its own unique keypair. We do, however, want it to persist so that we can 121 | # restart/reconnect and receive messages sent while we where restarting. 122 | key = derive_notifier_key("firebase") 123 | 124 | global omq, hivemind, firebase_app, queue_timer 125 | 126 | omq = OxenMQ(pubkey=key.public_key.encode(), privkey=key.encode()) 127 | 128 | cat = omq.add_category("notifier", AuthLevel.basic) 129 | cat.add_request_command("validate", validate) 130 | cat.add_command("push", push_notification) 131 | 132 | omq.add_timer(ping, datetime.timedelta(seconds=5)) 133 | 134 | conf = config.NOTIFY["firebase"] 135 | queue_timer = omq.add_timer( 136 | send_pending, 137 | datetime.timedelta(seconds=float(conf["notify_interval"])), 138 | thread=omq.add_tagged_thread("firebasenotify"), 139 | ) 140 | 141 | omq.start() 142 | 143 | hivemind = omq.connect_remote( 144 | Address(config.config.hivemind_sock), auth_level=AuthLevel.basic, ephemeral_routing_id=False 145 | ) 146 | 147 | firebase_app = FCMNotification( 148 | service_account_file=conf["token_file"], project_id="loki-5a81e" 149 | ) 150 | 151 | omq.send(hivemind, "admin.register_service", "firebase") 152 | 153 | 154 | def disconnect(flush_pending=True): 155 | global omq, hivemind, queue_timer 156 | omq.disconnect(hivemind) 157 | omq.cancel_timer(queue_timer) 158 | omq = None 159 | hivemind = None 160 | 161 | # In case we have pending incoming notifications still to process 162 | time.sleep(0.5) 163 | 164 | if flush_pending: 165 | send_pending() 166 | 167 | 168 | def run(startup_delay=4.0): 169 | """Runs the firebase notifier, forever.""" 170 | 171 | global omq 172 | 173 | if startup_delay > 0: 174 | time.sleep(startup_delay) 175 | 176 | logger.info("Starting firebase notifier") 177 | systemd.daemon.notify("STATUS=Initializing firebase notifier...") 178 | try: 179 | start() 180 | except Exception as e: 181 | logger.critical(f"Failed to start firebase notifier: {e}") 182 | raise e 183 | 184 | logger.info("Firebase notifier started") 185 | systemd.daemon.notify("READY=1\nSTATUS=Started") 186 | 187 | def sig_die(signum, frame): 188 | raise OSError(f"Caught signal {signal.Signals(signum).name}") 189 | 190 | try: 191 | signal.signal(signal.SIGHUP, sig_die) 192 | signal.signal(signal.SIGINT, sig_die) 193 | 194 | while omq is not None: 195 | time.sleep(3600) 196 | except Exception as e: 197 | logger.error(f"firebase notifier mule died via exception: {e}") 198 | 199 | 200 | if __name__ == "__main__": 201 | run(startup_delay=0) 202 | -------------------------------------------------------------------------------- /spns/notifiers/huawei.py: -------------------------------------------------------------------------------- 1 | # Huawei push notification server 2 | 3 | from .. import config 4 | from ..config import logger 5 | from ..core import SUBSCRIBE 6 | from .util import encrypt_notify_payload, derive_notifier_key, warn_on_except, NotifyStats 7 | 8 | from hms.src import push_admin 9 | from hms.src.push_admin import messaging as huawei_messaging 10 | 11 | import oxenc 12 | from oxenmq import OxenMQ, Message, Address, AuthLevel 13 | 14 | import datetime 15 | import time 16 | import json 17 | import signal 18 | import systemd.daemon 19 | from threading import Lock 20 | 21 | omq = None 22 | hivemind = None 23 | huawei_push_admin = None 24 | 25 | notify_queue = [] 26 | queue_lock = Lock() 27 | queue_timer = None 28 | 29 | # Whenever we add/change fields we increment this so that a Session client could figure out what it 30 | # is speaking to: 31 | SPNS_HUAWEI_VERSION = 1 32 | 33 | # If our JSON payload hits 4000 bytes then Huawei will reject it, so we limit ourselves to this size 34 | # *before* encryption + base64 encoding. If the source message exceeds this, we send an alternative 35 | # "too big" response in the metadata instead of including the message. 36 | MAX_MSG_SIZE = 2500 37 | 38 | # Firebase max simultaneous notifications: 39 | MAX_NOTIFIES = 500 40 | 41 | 42 | stats = NotifyStats() 43 | 44 | 45 | @warn_on_except 46 | def validate(msg: Message): 47 | parts = msg.data() 48 | if len(parts) != 2 or parts[0] != b"huawei": 49 | logger.warning("Internal error: invalid input to notifier.validate") 50 | msg.reply(str(SUBSCRIBE.ERROR.value), "Internal error") 51 | return 52 | 53 | try: 54 | data = json.loads(parts[1]) 55 | 56 | # We require just the device token, passed as `token`: 57 | token = data["token"] 58 | if not token: 59 | raise ValueError(f"Invalid huawei device token") 60 | msg.reply("0", token) 61 | except KeyError as e: 62 | msg.reply(str(SUBSCRIBE.BAD_INPUT.value), f"Error: missing required key {e}") 63 | except Exception as e: 64 | msg.reply(str(SUBSCRIBE.ERROR.value), str(e)) 65 | 66 | 67 | @warn_on_except 68 | def push_notification(msg: Message): 69 | data = oxenc.bt_deserialize(msg.data()[0]) 70 | 71 | enc_payload = encrypt_notify_payload(data, max_msg_size=MAX_MSG_SIZE) 72 | 73 | device_token = data[b"&"].decode() # unique service id, as we returned from validate 74 | 75 | msg = huawei_messaging.Message( 76 | data=json.dumps( 77 | {"enc_payload": oxenc.to_base64(enc_payload), "spns": f"{SPNS_HUAWEI_VERSION}"} 78 | ), 79 | token=[device_token], 80 | android=huawei_messaging.AndroidConfig( 81 | urgency=huawei_messaging.AndroidConfig.HIGH_PRIORITY 82 | ), 83 | ) 84 | 85 | global notify_queue, queue_lock 86 | with queue_lock: 87 | notify_queue.append(msg) 88 | 89 | 90 | @warn_on_except 91 | def send_pending(): 92 | global notify_queue, queue_lock, huawei_push_admin, stats 93 | with queue_lock: 94 | queue, notify_queue = notify_queue, [] 95 | 96 | i = 0 97 | while i < len(queue): 98 | result = huawei_messaging.send_message(queue[i], verify_peer=True) 99 | with stats.lock: 100 | stats.notifies += 1 101 | 102 | # FIXME: process/reschedule failures? 103 | 104 | i += 1 105 | 106 | 107 | @warn_on_except 108 | def ping(): 109 | global omq, hivemind, stats 110 | omq.send(hivemind, "admin.register_service", "huawei") 111 | omq.send(hivemind, "admin.service_stats", "huawei", oxenc.bt_serialize(stats.collect())) 112 | systemd.daemon.notify( 113 | f"WATCHDOG=1\nSTATUS=Running; {stats.total_notifies} notifications, " 114 | f"{stats.total_retries} retries, {stats.total_failures} failures" 115 | ) 116 | 117 | 118 | def start(): 119 | """Starts up the huawei push notification listener.""" 120 | 121 | # These do not and *should* not match hivemind or any other notifier: that is, each notifier 122 | # needs its own unique keypair. We do, however, want it to persist so that we can 123 | # restart/reconnect and receive messages sent while we where restarting. 124 | key = derive_notifier_key("huawei") 125 | 126 | global omq, hivemind, huawei_push_admin, queue_timer 127 | 128 | omq = OxenMQ(pubkey=key.public_key.encode(), privkey=key.encode()) 129 | 130 | cat = omq.add_category("notifier", AuthLevel.basic) 131 | cat.add_request_command("validate", validate) 132 | cat.add_command("push", push_notification) 133 | 134 | omq.add_timer(ping, datetime.timedelta(seconds=5)) 135 | 136 | conf = config.NOTIFY["huawei"] 137 | queue_timer = omq.add_timer( 138 | send_pending, 139 | datetime.timedelta(seconds=float(conf["notify_interval"])), 140 | thread=omq.add_tagged_thread("huaweinotify"), 141 | ) 142 | 143 | omq.start() 144 | 145 | hivemind = omq.connect_remote( 146 | Address(config.config.hivemind_sock), auth_level=AuthLevel.basic, ephemeral_routing_id=False 147 | ) 148 | 149 | huawei_push_admin = push_admin.initialize_app(conf["app_id"], conf["app_secret"]) 150 | 151 | omq.send(hivemind, "admin.register_service", "huawei") 152 | 153 | 154 | def disconnect(flush_pending=True): 155 | global omq, hivemind, queue_timer 156 | omq.disconnect(hivemind) 157 | omq.cancel_timer(queue_timer) 158 | omq = None 159 | hivemind = None 160 | 161 | # In case we have pending incoming notifications still to process 162 | time.sleep(0.5) 163 | 164 | if flush_pending: 165 | send_pending() 166 | 167 | 168 | def run(startup_delay=4.0): 169 | """Runs the huawei notifier, forever.""" 170 | 171 | global omq 172 | 173 | if startup_delay > 0: 174 | time.sleep(startup_delay) 175 | 176 | logger.info("Starting huawei notifier") 177 | systemd.daemon.notify("STATUS=Initializing huawei notifier...") 178 | try: 179 | start() 180 | except Exception as e: 181 | logger.critical(f"Failed to start huawei notifier: {e}") 182 | raise e 183 | 184 | logger.info("Huawei notifier started") 185 | systemd.daemon.notify("READY=1\nSTATUS=Started") 186 | 187 | def sig_die(signum, frame): 188 | raise OSError(f"Caught signal {signal.Signals(signum).name}") 189 | 190 | try: 191 | signal.signal(signal.SIGHUP, sig_die) 192 | signal.signal(signal.SIGINT, sig_die) 193 | 194 | while omq is not None: 195 | time.sleep(3600) 196 | except Exception as e: 197 | logger.error(f"huawei notifier mule died via exception: {e}") 198 | 199 | 200 | if __name__ == "__main__": 201 | run(startup_delay=0) 202 | -------------------------------------------------------------------------------- /spns/notifiers/util.py: -------------------------------------------------------------------------------- 1 | from nacl.hash import blake2b as blake2b_oneshot 2 | from nacl.public import PrivateKey 3 | from nacl.encoding import RawEncoder 4 | import nacl.utils 5 | from nacl.bindings import ( 6 | crypto_aead_xchacha20poly1305_ietf_encrypt, 7 | crypto_aead_xchacha20poly1305_ietf_NPUBBYTES, 8 | ) 9 | from oxenc import bt_serialize, bt_deserialize, to_base64 10 | import json 11 | from threading import Lock 12 | import time 13 | from collections import deque 14 | 15 | from .. import config 16 | 17 | 18 | # Returns a notifier key derived from the main hivemind key, given a notifier name. This ensures 19 | # distinct, private keys for each notifier without needing to generate multiple keys. 20 | def derive_notifier_key(name): 21 | return PrivateKey( 22 | blake2b_oneshot( 23 | config.PRIVKEYS["hivemind"].encode() + name.encode(), 24 | key=b"notifier", 25 | digest_size=32, 26 | encoder=RawEncoder, 27 | ) 28 | ) 29 | 30 | 31 | def encrypt_payload(msg: bytes, enc_key: bytes): 32 | nonce = nacl.utils.random(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) 33 | ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt( 34 | message=msg, key=enc_key, nonce=nonce, aad=None 35 | ) 36 | return nonce + ciphertext 37 | 38 | 39 | def encrypt_notify_payload(data: dict, max_msg_size: int = 2500): 40 | enc_key = data[b"^"] 41 | 42 | metadata = {"@": data[b"@"].hex(), "#": data[b"#"].decode(), "n": data[b"n"], "t": data[b"t"], "z": data[b"z"]} 43 | body = data.get(b"~") 44 | 45 | if body: 46 | metadata["l"] = len(body) 47 | if max_msg_size >= 0 and len(body) > max_msg_size: 48 | metadata["B"] = True 49 | body = None 50 | 51 | payload = bt_serialize([json.dumps(metadata), body] if body else [metadata]) 52 | over = len(payload) % 256 53 | if over: 54 | payload += b"\0" * (256 - over) 55 | 56 | return encrypt_payload(payload, enc_key) 57 | 58 | 59 | def warn_on_except(f): 60 | """ 61 | Wrapper that catches and logs exceptions, used for endpoint wrapping where an exception just 62 | gets eaten by oxenmq anyway). 63 | """ 64 | 65 | def wrapper(*args, **kwargs): 66 | try: 67 | f(*args, **kwargs) 68 | except Exception as e: 69 | config.logger.warning(f"Exception in {f.__name__}: {e}") 70 | 71 | return wrapper 72 | 73 | 74 | class NotifyStats: 75 | def __init__(self): 76 | self.lock = Lock() 77 | 78 | # Stats since the last report: 79 | self.notifies = 0 # Total successful notifications 80 | self.notify_retries = 0 # Successful notifications that required 1 or more retries 81 | self.failures = 0 # Failed notifications (i.e. neither first attempt nor retries worked) 82 | 83 | self.total_notifies = 0 84 | self.total_retries = 0 85 | self.total_failures = 0 86 | 87 | # History of recent (time, notifies) values: 88 | self.notify_hist = deque() 89 | 90 | def collect(self): 91 | with self.lock: 92 | now = time.time() 93 | report = { 94 | "+notifies": self.notifies, 95 | "+notify_retries": self.notify_retries, 96 | "+failures": self.failures, 97 | } 98 | 99 | for mins in (60, 10, 1): 100 | cutoff, since, summation, n = now - mins * 60, 0, 0, 0 101 | for i in range(len(self.notify_hist)): 102 | t, notif = self.notify_hist[i] 103 | if t >= cutoff: 104 | if n == 0: 105 | since = self.notify_hist[i - 1 if i > 0 else i][0] 106 | n += 1 107 | summation += notif 108 | 109 | if n > 0: 110 | report[f"notifies_per_day.{mins}m"] = round( 111 | summation / n / (now - since) * 86400 112 | ) 113 | 114 | cutoff = now - 3600 115 | while self.notify_hist and self.notify_hist[0][0] < cutoff: 116 | self.notify_hist.popleft() 117 | self.notify_hist.append((now, self.notifies)) 118 | self.total_notifies += self.notifies 119 | self.total_retries += self.notify_retries 120 | self.total_failures += self.failures 121 | self.notifies, self.notify_retries, self.failures = 0, 0, 0 122 | 123 | return report 124 | -------------------------------------------------------------------------------- /spns/pg.cpp: -------------------------------------------------------------------------------- 1 | #include "pg.hpp" 2 | 3 | #include 4 | 5 | namespace spns { 6 | 7 | namespace log = oxen::log; 8 | static auto cat = log::Cat("pg"); 9 | 10 | PGConnPool::PGConnPool(std::string pg_connect, int initial_conns) : 11 | pg_connect_{std::move(pg_connect)} { 12 | log::info(cat, "Connecting to postgresql database @ {}", pg_connect_); 13 | auto conn0 = make_conn(); 14 | if (initial_conns > 0) { 15 | idle_conns_.emplace_back(std::move(conn0), steady_clock::now()); 16 | for (int i = 1; i < initial_conns; i++) 17 | idle_conns_.emplace_back(make_conn(), steady_clock::now()); 18 | } 19 | } 20 | 21 | PGConn PGConnPool::get() { 22 | std::unique_ptr conn; 23 | while (!conn) { 24 | conn = pop_conn(); 25 | if (!conn) // no conn available 26 | break; 27 | else if (!conn->is_open()) 28 | conn.reset(); // found one, but it's dead; try again 29 | } 30 | clear_idle_conns(); 31 | 32 | if (!conn) 33 | conn = make_conn(); 34 | return PGConn{*this, std::move(conn)}; 35 | } 36 | 37 | void PGConnPool::release(std::unique_ptr conn) { 38 | { 39 | std::lock_guard lock{mutex_}; 40 | idle_conns_.emplace_back(std::move(conn), steady_clock::now()); 41 | } 42 | clear_idle_conns(); 43 | } 44 | 45 | void PGConnPool::clear_idle_conns() { 46 | std::lock_guard lock{mutex_}; 47 | if (max_idle >= 0) 48 | while (idle_conns_.size() > max_idle) 49 | idle_conns_.pop_front(); 50 | 51 | if (max_idle_time > 0s) { 52 | auto cutoff = steady_clock::now() - max_idle_time; 53 | while (idle_conns_.front().second < cutoff) 54 | idle_conns_.pop_front(); 55 | } 56 | } 57 | 58 | std::unique_ptr PGConnPool::pop_conn() { 59 | std::lock_guard lock{mutex_}; 60 | if (!idle_conns_.empty()) { 61 | auto conn = std::move(idle_conns_.back().first); 62 | idle_conns_.pop_back(); 63 | return conn; 64 | } 65 | return nullptr; 66 | } 67 | 68 | std::unique_ptr PGConnPool::make_conn() { 69 | log::debug(cat, "Creating pg connection"); 70 | std::lock_guard lock{mutex_}; 71 | count_++; 72 | return std::make_unique(pg_connect_); 73 | } 74 | 75 | PGConn::~PGConn() { 76 | if (conn_) 77 | pool_.release(std::move(conn_)); 78 | } 79 | 80 | } // namespace spns 81 | 82 | namespace pqxx { 83 | 84 | spns::Int16ArrayLoader string_traits::from_string(std::string_view in) { 85 | if (in.size() <= 2) 86 | return {}; 87 | auto* pos = in.data(); 88 | assert(*pos == '{'); 89 | pos++; 90 | auto* back = &in.back(); 91 | assert(*back == '}'); 92 | spns::Int16ArrayLoader vals; 93 | vals.a.reserve(std::count(pos, back, ',')); 94 | while (pos < back) { 95 | auto& ns = vals.a.emplace_back(); 96 | auto [ptr, ec] = std::from_chars(pos, back, ns); 97 | assert(ec == std::errc()); 98 | assert(ptr == back || *ptr == ','); 99 | pos = ptr + 1; 100 | } 101 | return vals; 102 | } 103 | 104 | } // namespace pqxx 105 | -------------------------------------------------------------------------------- /spns/pg.hpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "bytes.hpp" 7 | 8 | namespace spns { 9 | 10 | using namespace std::literals; 11 | 12 | class PGConnPool; 13 | 14 | // smart-pointer-like wrapper around a pqxx::connection; when this wrapper is destructed the 15 | // connection is automatically returned to the PGConnPool. This wrapper *must not* outlive the 16 | // PGConnPool that created it. 17 | class PGConn { 18 | PGConnPool& pool_; 19 | std::unique_ptr conn_; 20 | 21 | friend class PGConnPool; 22 | 23 | PGConn(PGConnPool& pool, std::unique_ptr conn) : 24 | pool_{pool}, conn_{std::move(conn)} {} 25 | 26 | public: 27 | // Closes/destroys the underlying connection, which also means that this connection will not be 28 | // readded to the pool on destruction of the PGConn wrapper. 29 | void close() { conn_.reset(); } 30 | 31 | // Destructor; returns this connection to the pool (unless `close()` has been called). 32 | ~PGConn(); 33 | 34 | pqxx::connection& operator*() const noexcept { return conn_.operator*(); } 35 | pqxx::connection* operator->() const noexcept { return conn_.operator->(); } 36 | 37 | operator pqxx::connection&() const noexcept { return **this; } 38 | }; 39 | 40 | class PGConnPool { 41 | using steady_clock = std::chrono::steady_clock; 42 | using steady_time = steady_clock::time_point; 43 | std::string pg_connect_; 44 | // queue of connections + time added to the idle queue 45 | std::deque, steady_time>> idle_conns_; 46 | std::mutex mutex_; 47 | int count_ = 0; 48 | 49 | public: 50 | /// After how long of being unused before we kill off idle connections. (This isn't an active 51 | /// timer: connections get killed off only when retrieving or releasing a connection). 0 or 52 | /// negative mean there is no idle timeout. After changing this you may want to call 53 | /// `clear_idle_conns()` to apply the new setting to currently idle connections. 54 | std::chrono::milliseconds max_idle_time = 10min; 55 | 56 | /// Maximum number of idle connections we will keep alive. If 0 then we never keep any idle 57 | /// connections at all and each call to `get()` will have to reconnect. 58 | /// 59 | /// If negative then there is no limit (aside from max_idle_time) on the number of idle 60 | /// connections that will be kept around. 61 | /// 62 | /// After changing this you may want to call `clear_idle_conns()` to apply the new setting. 63 | int max_idle = -1; 64 | 65 | /// Create the connection pool and establish the first connection(s), throwing if we are unable 66 | /// to connect. We always establish at least one connection to test the connection; if 67 | /// initial_conns is 0 then we close it rather than returning it to the initial pool. 68 | PGConnPool(std::string pg_connect, int initial_conns = 1); 69 | 70 | /// Gets a connection; if none are available a new connection is constructed. This tests the 71 | /// status of the connection before returning it, discarding any connections that are no longer 72 | /// open (e.g. because of error or server timeout). 73 | /// 74 | /// We always return the most-recently-used connection (so that excess connections have a chance 75 | /// to reach the max idle time). 76 | /// 77 | /// Calling this function also triggers a check for excess idle connections after selecting a 78 | /// connection from the pool. 79 | /// 80 | /// Returns a PGConn wrapper which is smart-pointer-like and automatically returns the 81 | /// connection to the pool upon destruction. You *must ensure* that the returned value does not 82 | /// outlive the creating PGConnPool. 83 | PGConn get(); 84 | 85 | /// Releases a connection back into the pool for future use. This is not called directly, but 86 | /// instead implicit during destruction of the PGConn wrapper. 87 | void release(std::unique_ptr conn); 88 | 89 | /// Clears any connections that have been idle longer than `max_idle`. This is called 90 | /// automatically whenever `release` or `get` are called, but can be called externally (e.g. on 91 | /// a timer) if more strict idle time management is desired. 92 | void clear_idle_conns(); 93 | 94 | protected: 95 | std::unique_ptr pop_conn(); 96 | 97 | std::unique_ptr make_conn(); 98 | }; 99 | 100 | // Helper for extracting namespaces from a pg array 101 | struct Int16ArrayLoader { 102 | std::vector a; 103 | }; 104 | 105 | } // namespace spns 106 | 107 | namespace pqxx { 108 | 109 | template <> 110 | inline const std::string type_name{"spns::AccountID"}; 111 | template <> 112 | inline const std::string type_name{"spns::Ed25519PK"}; 113 | template <> 114 | inline const std::string type_name{"spns::SubaccountTag"}; 115 | template <> 116 | inline const std::string type_name{"spns::Signature"}; 117 | template <> 118 | inline const std::string type_name{"spns::EncKey"}; 119 | 120 | template >> 121 | struct spns_byte_helper { 122 | static constexpr size_t SIZE = T::SIZE; 123 | static T from_string(std::string_view text) { 124 | const auto size = internal::size_unesc_bin(text.size()); 125 | if (size != SIZE) 126 | throw conversion_error{ 127 | "Invalid byte length (" + std::to_string(size) + ") for spns::bytes<" + 128 | std::to_string(SIZE) + ">-derived object\n" 129 | #ifndef NDEBUG 130 | + std::string{text} 131 | #endif 132 | }; 133 | T val; 134 | internal::unesc_bin(text, val.data()); 135 | return val; 136 | } 137 | 138 | using BSV_traits = string_traits>; 139 | 140 | static zview to_buf(char* begin, char* end, const T& val) { 141 | return BSV_traits::to_buf(begin, end, {val.data(), val.size()}); 142 | } 143 | static char* into_buf(char* begin, char* end, const T& val) { 144 | return BSV_traits::into_buf(begin, end, {val.data(), val.size()}); 145 | } 146 | static std::size_t size_buffer(const T&) noexcept { 147 | return internal::size_esc_bin(SIZE); 148 | } 149 | }; 150 | 151 | template 152 | struct nullness>> : pqxx::no_null {}; 153 | 154 | template <> 155 | struct string_traits : spns_byte_helper {}; 156 | template <> 157 | struct string_traits : spns_byte_helper {}; 158 | template <> 159 | struct string_traits : spns_byte_helper {}; 160 | template <> 161 | struct string_traits : spns_byte_helper {}; 162 | template <> 163 | struct string_traits : spns_byte_helper {}; 164 | 165 | template <> 166 | struct string_traits { 167 | static spns::Int16ArrayLoader from_string(std::string_view in); 168 | }; 169 | 170 | template <> 171 | struct nullness : pqxx::no_null {}; 172 | 173 | } // namespace pqxx 174 | -------------------------------------------------------------------------------- /spns/pybind.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include 5 | #include 6 | 7 | #include "bytes.hpp" 8 | #include "config.hpp" 9 | #include "hive/subscription.hpp" 10 | #include "hivemind.hpp" 11 | 12 | namespace py = pybind11; 13 | using namespace py::literals; 14 | 15 | struct HiveMindController { 16 | std::unique_ptr hivemind; 17 | 18 | HiveMindController(spns::Config conf) : hivemind{new spns::HiveMind{std::move(conf)}} {} 19 | 20 | void stop() { hivemind.reset(); } 21 | }; 22 | 23 | namespace pybind11::detail { 24 | template 25 | struct type_caster>> { 26 | 27 | PYBIND11_TYPE_CASTER(T, const_name("bytes") + const_name()); 28 | 29 | bool load(handle src, bool) { 30 | if (py::isinstance(src)) { 31 | auto sv = py::cast(src).operator std::string_view(); 32 | if (sv.size() == T::SIZE) { 33 | std::memcpy(value.data(), sv.data(), T::SIZE); 34 | return true; 35 | } 36 | } 37 | return false; 38 | } 39 | 40 | static handle cast(const T& src, return_value_policy /* policy */, handle /* parent */) { 41 | return py::bytes(reinterpret_cast(src.data()), src.size()); 42 | } 43 | }; 44 | } // namespace pybind11::detail 45 | 46 | PYBIND11_MODULE(core, m) { 47 | 48 | using namespace spns; 49 | 50 | // Python is kind of a pain in the ass about destruction, so use a wrapper class around the 51 | // actual HiveMind that lets us explicitly destruct. 52 | py::class_{m, "HiveMind"} 53 | .def(py::init(), 54 | "Construts and starts a new HiveMind instance. The instance will continue to run " 55 | "until `.stop()` is called.", 56 | "config"_a) 57 | .def("stop", &HiveMindController::stop); 58 | 59 | py::class_{m, "Config"} 60 | .def(py::init<>()) 61 | .def_property( 62 | "oxend_rpc", 63 | [](Config& self) { return self.oxend_rpc.full_address(); }, 64 | [](Config& self, std::string addr) { 65 | self.oxend_rpc = oxenmq::address{std::move(addr)}; 66 | }, 67 | "oxenmq address of the companion oxend RPC to use") 68 | .def_readwrite("pg_connect", &Config::pg_connect, "postgresql connection URL") 69 | .def_readwrite( 70 | "hivemind_sock", &Config::hivemind_sock, "local hivemind admin oxenmq socket") 71 | .def_readwrite( 72 | "hivemind_curve", 73 | &Config::hivemind_curve, 74 | "optional secondary hivemind curve-enabled listening socket") 75 | .def_readwrite( 76 | "hivemind_curve_admin", 77 | &Config::hivemind_curve_admin, 78 | "set of X25519 pubkeys recognized as admin for incoming `hivemind_curve` " 79 | "connections") 80 | .def_readwrite( 81 | "pubkey", 82 | &Config::pubkey, 83 | "X25519 server pubkey; must be set (the default value will not work)") 84 | .def_readwrite( 85 | "privkey", 86 | &Config::privkey, 87 | "X25519 server privkey; must be set (the default value will not work)") 88 | .def_property( 89 | "filter_lifetime", 90 | [](Config& self) { return self.filter_lifetime.count(); }, 91 | [](Config& self, int64_t seconds) { self.filter_lifetime = 1s * seconds; }, 92 | "the notification replay filter lifetime, in seconds") 93 | .def_property( 94 | "notifier_wait", 95 | [](Config& self) { return self.notifier_wait.count(); }, 96 | [](Config& self, int64_t milliseconds) { 97 | self.notifier_wait = 1ms * milliseconds; 98 | }, 99 | "how long, in milliseconds, after initialization to wait for notifier servers " 100 | "to register themselves with the HiveMind instance") 101 | .def_readwrite( 102 | "notifiers_expected", 103 | &Config::notifiers_expected, 104 | "Set of notification services that we expect; if non-empty then we will stop " 105 | "the `notifier_wait` time early once we have registered notifiers for all the " 106 | "values set here.") 107 | .def_property( 108 | "subs_interval", 109 | [](Config& self) { return self.subs_interval.count(); }, 110 | [](Config& self, int64_t seconds) { self.subs_interval = 1s * seconds; }, 111 | "how frequently, in seconds, between subscription rechecks (for push renewals, " 112 | "expiries, etc.)") 113 | .def_readwrite( 114 | "omq_push_instances", 115 | &Config::omq_push_instances, 116 | "How many dedicated oxenmq instances to use for handle push notifications; if " 117 | "1 or greater then this many separate oxenmq instances will be started to deal " 118 | "with push requests; if 0 then the main oxenmq server will be used for " 119 | "everything.") 120 | .def_readwrite( 121 | "max_pending_connects", 122 | &Config::max_pending_connects, 123 | "maximum number of permitted simultaneous connection attempts. (This is not " 124 | "the number of simultaneous connections, just how many we new connections we " 125 | "will attempt at once"); 126 | 127 | class Logger {}; 128 | py::class_{m, "logger"} 129 | .def_static( 130 | "start", 131 | [](const std::string& out) { 132 | oxen::log::clear_sinks(); 133 | if (out == "stdout" || out == "-" || out == "") 134 | oxen::log::add_sink(oxen::log::Type::Print, "stdout"); 135 | else if (out == "stderr") 136 | oxen::log::add_sink(oxen::log::Type::Print, "stderr"); 137 | else 138 | oxen::log::add_sink(oxen::log::Type::File, out); 139 | }) 140 | .def_static( 141 | "set_level", 142 | [](const std::string& level) { 143 | oxen::log::reset_level(oxen::log::level_from_string(level)); 144 | }, 145 | "Sets/resets the log level of all spns.core log categories to the given " 146 | "value.\n" 147 | "Can be any of 'trace', 'debug', 'info', 'warn', 'error', 'critical', or " 148 | "'none'.", 149 | "level"_a) 150 | .def_static( 151 | "set_level", 152 | [](const std::string& cat, const std::string& level) { 153 | oxen::log::set_level(cat, oxen::log::level_from_string(level)); 154 | }, 155 | "Sets/resets the log level of a single spns.core log categories to the given " 156 | "value.\n" 157 | "Can be any of 'trace', 'debug', 'info', 'warning', 'error', 'critical', or " 158 | "'none'.", 159 | "category"_a, 160 | "level"_a) 161 | .def_static( 162 | "get_level", 163 | [](const std::string& cat) { oxen::log::get_level(cat); }, 164 | "Gets the log level of the given spns.core log category") 165 | .def_static( 166 | "get_level", 167 | [](const std::string& cat) { oxen::log::get_level(cat); }, 168 | "Gets the log level of the given spns.core log category") 169 | .def_static( 170 | "get_level", 171 | []() { oxen::log::get_level_default(); }, 172 | "Gets the default log level of spns.core categories (those that have not been " 173 | "changed via a category-specific `set_level`)") 174 | // 175 | ; 176 | 177 | static_assert( 178 | static_cast(hive::SUBSCRIBE::_END) == 6, 179 | "pybind11 binding is missing SUBSCRIBE enum elements"); 180 | 181 | py::enum_{m, "SUBSCRIBE"} 182 | .value("OK", hive::SUBSCRIBE::OK) 183 | .value("BAD_INPUT", hive::SUBSCRIBE::BAD_INPUT) 184 | .value("SERVICE_NOT_AVAILABLE", hive::SUBSCRIBE::SERVICE_NOT_AVAILABLE) 185 | .value("SERVICE_TIMEOUT", hive::SUBSCRIBE::SERVICE_TIMEOUT) 186 | .value("ERROR", hive::SUBSCRIBE::ERROR) 187 | .value("INTERNAL_ERROR", hive::SUBSCRIBE::INTERNAL_ERROR) 188 | .export_values(); 189 | } 190 | -------------------------------------------------------------------------------- /spns/register.py: -------------------------------------------------------------------------------- 1 | from . import web 2 | from .web import app 3 | from .core import SUBSCRIBE 4 | from flask import request, jsonify, Response 5 | 6 | 7 | @app.post("/subscribe") 8 | def subscribe(): 9 | """ 10 | Register for push notifications. 11 | 12 | The body of this request is a JSON object This expects JSON input of: 13 | 14 | { 15 | "pubkey": "05123...", 16 | "session_ed25519": "abc123...", 17 | "subaccount": "def789...", 18 | "subaccount_sig": "aGVsbG9...", 19 | "namespaces": [-400,0,1,2,17], 20 | "data": true, 21 | "sig_ts": 1677520760, 22 | "signature": "f8efdd120007...", 23 | "service": "apns", 24 | "service_info": { ... }, 25 | "enc_key": "abcdef..." 26 | } 27 | 28 | or an array of such JSON objects (to submit multiple subscriptions at once). 29 | 30 | where keys are as follows (note that all bytes values shown above in hex can be passed either as 31 | hex or base64): 32 | 33 | - pubkey -- the 33-byte account being subscribed to; typically a session ID. 34 | - session_ed25519 -- when the `pubkey` value starts with 05 (i.e. a session ID) this is the 35 | underlying ed25519 32-byte pubkey associated with the session ID. When not 05, this field 36 | should not be provided. 37 | - subaccount -- 36-byte swarm authentication subccount tag provided by an account owner 38 | - subaccount_sig -- 64-byte Ed25519 signature of the subaccount tag signed by the account owner 39 | - namespaces -- list of integer namespace (-32768 through 32767). These must be sorted in 40 | ascending order. 41 | - data -- if provided and true then notifications will include the body of the message (as long 42 | as it isn't too large); if false then the body will not be included in notifications. 43 | - sig_ts -- the signature unix timestamp (seconds, not ms); see below. 44 | - signature -- the 64-byte Ed25519 signature; see below. 45 | - service -- the string identifying the notification service, such as "apns" or "firebase". 46 | - service_info -- dict of service-specific data; typically this includes a "token" field with a 47 | device-specific token, but different services may have different input requirements. 48 | - enc_key -- 32-byte encryption key; notification payloads sent to the device will be encrypted 49 | with XChaCha20-Poly1305 using this key. 50 | 51 | Notification subscriptions are unique per pubkey/service/service_token which means that 52 | re-subscribing with the same pubkey/service/token renews (or updates, if there are changes in 53 | other parameters such as the namespaces) an existing subscription. 54 | 55 | Signatures: 56 | 57 | The signature data collected and stored here is used by the PN server to subscribe to the swarms 58 | for the given accounts; the specific rules are governed by the storage server, but in general: 59 | 60 | - a signature must have been produced (via the timestamp) within the past 14 days. It is 61 | recommended that clients generate a new signature whenever they re-subscribe, and that 62 | re-subscriptions happen more frequently than once every 14 days. 63 | 64 | - a signature is signed using the account's Ed25519 private key (or delegated Ed25519 65 | subaccount, if using subaccount authentication), and signs the value: 66 | 67 | "MONITOR" || HEX(ACCOUNT) || SIG_TS || DATA01 || NS[0] || "," || ... || "," || NS[n] 68 | 69 | where SIG_TS is the `sig_ts` value as a base-10 string; DATA01 is either "0" or "1" depending 70 | on whether the subscription wants message data included; and the trailing NS[i] values are a 71 | comma-delimited list of namespaces that should be subscribed to, in the same sorted order as 72 | the `namespaces` parameter. 73 | 74 | Returns json such as: 75 | 76 | { "success": true, "added": true } 77 | 78 | on acceptance of a new registration, or: 79 | 80 | { "success": true, "updated": true } 81 | 82 | on renewal/update of an existing device registration. 83 | 84 | On error returns: 85 | 86 | { "error": CODE, "message": "some error description" } 87 | 88 | where CODE is one of the integer values of the spns/hive/subscription.hpp SUBSCRIBE enum. 89 | 90 | If called with an array of subscriptions then an array of such json objects is returned, where 91 | return value [n] is the response for request [n]. 92 | """ 93 | 94 | clen = request.content_length 95 | if clen is None: 96 | return jsonify( 97 | {"error": SUBSCRIBE.BAD_INPUT.value, "message": "Invalid request: request body missing"} 98 | ) 99 | if request.content_length > 100_000: 100 | return jsonify( 101 | {"error": SUBSCRIBE.BAD_INPUT.value, "message": "Invalid request: request too large"} 102 | ) 103 | 104 | try: 105 | resp = web.omq.request_future(web.hivemind, "push.subscribe", request.get_data()).get() 106 | except TimeoutError as e: 107 | app.logger.warning(f"Timeout proxying subscription to hivemind backend ({e})") 108 | return jsonify( 109 | { 110 | "error": SUBSCRIBE.SERVICE_TIMEOUT.value, 111 | "message": "Timeout waiting for push notification backend", 112 | } 113 | ) 114 | except Exception as e: 115 | app.logger.warning(f"Error proxying subscription to hivemind backend: {e}") 116 | return jsonify( 117 | { 118 | "error": SUBSCRIBE.ERROR.value, 119 | "message": "An error occured while processing your request", 120 | } 121 | ) 122 | 123 | return Response(resp[0], mimetype="application/json") 124 | 125 | 126 | @app.post("/unsubscribe") 127 | def unsubscribe(): 128 | """ 129 | Removes a device registration from push notifications. 130 | 131 | The request should be json with a body with a subset of the /subscribe parameters: 132 | 133 | { 134 | "pubkey": "05123...", 135 | "session_ed25519": "abc123...", 136 | "subaccount": "def789...", 137 | "subaccount_sig": "aGVsbG9...", 138 | "sig_ts": 1677520760, 139 | "signature": "f8efdd120007...", 140 | "service": "apns", 141 | "service_info": { ... } 142 | } 143 | 144 | (or a list of such elements). 145 | 146 | The signature here is over the value: 147 | 148 | "UNSUBSCRIBE" || HEX(ACCOUNT) || SIG_TS 149 | 150 | and SIG_TS must be within 24 hours of the current time. 151 | 152 | On success returns: 153 | 154 | { "success": true, "removed": true } 155 | 156 | if a registration for the given pubkey/service info was found and removed; or: 157 | 158 | { "success": true, "removed": false } 159 | 160 | if the request was accepted (i.e. signature validated) but the registration did not exist (e.g. 161 | was already removed). 162 | 163 | On failure returns: 164 | 165 | { "error": INT, "message": "some error message" } 166 | 167 | where INT is one of the error integers from spns/hive/subscription.cpp's SUBSCRIBE enum. 168 | 169 | If this request is invoked with a list of unsubscribe requests then a list of such error objects 170 | is returned, one for each unsubscribe request. 171 | """ 172 | 173 | clen = request.content_length 174 | if clen is None: 175 | return jsonify( 176 | {"error": SUBSCRIBE.BAD_INPUT.value, "message": "Invalid request: request body missing"} 177 | ) 178 | if request.content_length > 100_000: 179 | return jsonify( 180 | {"error": SUBSCRIBE.BAD_INPUT.value, "message": "Invalid request: request too large"} 181 | ) 182 | 183 | try: 184 | resp = web.omq.request_future(web.hivemind, "push.unsubscribe", request.get_data()).get() 185 | except TimeoutError as e: 186 | app.logger.warning(f"Timeout proxying subscription to hivemind backend ({e})") 187 | return jsonify( 188 | { 189 | "error": SUBSCRIBE.SERVICE_TIMEOUT.value, 190 | "message": "Timeout waiting for push notification backend", 191 | } 192 | ) 193 | except Exception: 194 | app.logger.warning(f"Error proxying subscription to hivemind backend: {e}") 195 | return jsonify( 196 | { 197 | "error": SUBSCRIBE.ERROR.value, 198 | "message": "An error occured while processing your request", 199 | } 200 | ) 201 | 202 | return Response(resp[0], mimetype="application/json") 203 | -------------------------------------------------------------------------------- /spns/schema-upgrades.pgsql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS subaccount_tag BYTEA; 3 | ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS subaccount_sig BYTEA; 4 | ALTER TABLE subscriptions DROP COLUMN IF EXISTS subkey_tag; 5 | 6 | -- vim:ft=sql 7 | -------------------------------------------------------------------------------- /spns/schema.pgsql: -------------------------------------------------------------------------------- 1 | 2 | CREATE TABLE subscriptions ( 3 | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 4 | account BYTEA NOT NULL, -- 33-byte swarm account id (e.g. session id or closed group id) 5 | session_ed25519 BYTEA, -- for a 05 account (session id) this is the 32-byte ed25519 key 6 | subaccount_tag BYTEA, -- optional subaccount tag for subaccount authentication 7 | subaccount_sig BYTEA, -- optional subaccount tag signature for subaccount authentication 8 | signature BYTEA NOT NULL, -- subscription authentication signature (for swarm) 9 | signature_ts BIGINT NOT NULL, -- unix timestamp of the auth signature (for swarm) 10 | want_data BOOLEAN NOT NULL, -- whether the client wants msg data included in the notification 11 | enc_key BYTEA NOT NULL, -- encryption key with which we encrypt the payload 12 | service VARCHAR NOT NULL, -- subscription service type ("apns", "firebase") 13 | svcid VARCHAR NOT NULL, -- unique device/app identifier 14 | svcdata BYTEA, -- arbitrary data for subscription service 15 | 16 | UNIQUE(account, service, svcid) 17 | ); 18 | 19 | CREATE INDEX subscriptions_signature_ts_idx ON subscriptions(signature_ts); 20 | 21 | CREATE INDEX subscriptions_service_idx ON subscriptions(service); 22 | 23 | CREATE TABLE sub_namespaces ( 24 | subscription BIGINT NOT NULL REFERENCES subscriptions ON DELETE CASCADE, 25 | namespace SMALLINT NOT NULL, 26 | 27 | PRIMARY KEY(subscription, namespace) 28 | ); 29 | 30 | CREATE TABLE service_stats ( 31 | service VARCHAR NOT NULL, 32 | name VARCHAR NOT NULL, 33 | val_str VARCHAR, 34 | val_int BIGINT, 35 | 36 | PRIMARY KEY(service, name), 37 | CHECK((val_str IS NULL AND val_int IS NOT NULL) OR (val_str IS NOT NULL AND val_int IS NULL)) 38 | ); 39 | 40 | -- vim:ft=sql 41 | -------------------------------------------------------------------------------- /spns/subrequest.py: -------------------------------------------------------------------------------- 1 | from .web import app 2 | 3 | from flask import request, g 4 | from io import BytesIO 5 | import traceback 6 | from typing import Optional, Union 7 | import urllib.parse 8 | 9 | 10 | def make_subrequest( 11 | method: str, 12 | path: str, 13 | *, 14 | headers={}, 15 | content_type: Optional[str] = None, 16 | body: Optional[Union[bytes, memoryview]] = None, 17 | json: Optional[Union[dict, list]] = None, 18 | user_reauth: bool = False, 19 | ): 20 | """ 21 | Makes a subrequest from the given parameters, returns the response object and a dict of 22 | lower-case response headers keys to header values. 23 | 24 | Parameters: 25 | method - the HTTP method, e.g. GET or POST 26 | path - the request path (optionally including a query string) 27 | headers - dict of HTTP headers for the request 28 | content_type - the content-type of the request (for POST/PUT methods) 29 | body - the bytes content of the body of a POST/PUT method. If specified then content_type will 30 | default to 'application/octet-stream'. 31 | json - a json value to dump as the body of the request. If specified then content_type will 32 | default to 'applicaton/json'. 33 | user_reauth - if True then we allow user re-authentication on the subrequest based on its 34 | X-SOGS-* headers; if False (the default) then the user auth on the outer request is preserved 35 | (even if it was None) and inner request auth headers will be ignored. 36 | """ 37 | 38 | http_headers = {"HTTP_{}".format(h.upper().replace("-", "_")): v for h, v in headers.items()} 39 | 40 | if content_type is None: 41 | if "HTTP_CONTENT_TYPE" in http_headers: 42 | content_type = http_headers["HTTP_CONTENT_TYPE"] 43 | elif body is not None: 44 | content_type = "application/octet-stream" 45 | elif json is not None: 46 | content_type = "application/json" 47 | else: 48 | content_type = "" 49 | 50 | for x in ("HTTP_CONTENT_TYPE", "HTTP_CONTENT_LENGTH"): 51 | if x in http_headers: 52 | del http_headers[x] 53 | 54 | if body is None: 55 | if json is not None: 56 | from json import dumps 57 | 58 | body = dumps(json, separators=(",", ":")).encode() 59 | else: 60 | body = b"" 61 | 62 | body_input = BytesIO(body) 63 | content_length = len(body) 64 | 65 | if "?" in path: 66 | path, query_string = path.split("?", 1) 67 | else: 68 | query_string = "" 69 | 70 | if "%" in path: 71 | path = urllib.parse.unquote(path, errors="strict") 72 | 73 | # Werkzeug has some screwy internals: it requires PATH_INFO to be a bastardized string 74 | # masquerading as bytes: it encodes the string as latin1, then decodes *those* bytes to utf-8. 75 | # So we have to muck around here to get our unicode as utf-8 bytes then shove those into a 76 | # latin1 string. WTF. 77 | monkey_path = path 78 | if any(ord(c) > 127 for c in path): 79 | monkey_path = path.encode("utf-8").decode("latin1") 80 | 81 | # Set up the wsgi environ variables for the subrequest (see PEP 0333) 82 | subreq_env = { 83 | **request.environ, 84 | "REQUEST_METHOD": method, 85 | "PATH_INFO": monkey_path, 86 | "QUERY_STRING": query_string, 87 | "CONTENT_TYPE": content_type, 88 | "CONTENT_LENGTH": content_length, 89 | **http_headers, 90 | "wsgi.input": body_input, 91 | "flask._preserve_context": False, 92 | } 93 | 94 | try: 95 | app.logger.debug(f"Initiating sub-request for {method} {path}") 96 | g.user_reauth = user_reauth 97 | with app.request_context(subreq_env): 98 | try: 99 | response = app.full_dispatch_request() 100 | except Exception as e: 101 | response = app.make_response(app.handle_exception(e)) 102 | if response.status_code >= 400: 103 | app.logger.warning( 104 | f"Sub-request for {method} {path} returned status {response.status_code}" 105 | ) 106 | return ( 107 | response, 108 | { 109 | k.lower(): v 110 | for k, v in response.get_wsgi_headers(subreq_env) 111 | if k.lower() != "content-length" 112 | }, 113 | ) 114 | 115 | except Exception: 116 | app.logger.warning(f"Sub-request for {method} {path} failed: {traceback.format_exc()}") 117 | raise 118 | -------------------------------------------------------------------------------- /spns/swarmpubkey.cpp: -------------------------------------------------------------------------------- 1 | #include "swarmpubkey.hpp" 2 | 3 | #include 4 | #include 5 | 6 | namespace spns { 7 | 8 | static_assert(is_bytes); 9 | static_assert(!is_bytes>); 10 | 11 | static uint64_t calc_swarm_space(const AccountID& id) { 12 | uint64_t res = 0; 13 | for (int i = 1; i < 33; i += 8) 14 | res ^= oxenc::load_big_to_host(id.data() + i); 15 | return res; 16 | } 17 | 18 | SwarmPubkey::SwarmPubkey(AccountID account_id, std::optional ed, bool _skip_validation) : 19 | id{std::move(account_id)}, swarm_space{calc_swarm_space(id)} { 20 | 21 | if (ed) { 22 | if (id.front() != std::byte{0x05}) 23 | throw std::invalid_argument{ 24 | "session_ed25519 may only be used with 05-prefixed session IDs"}; 25 | ed25519 = std::move(*ed); 26 | session_ed = true; 27 | if (!_skip_validation) { 28 | AccountID derived_pk; 29 | derived_pk[0] = std::byte{0x05}; 30 | int rc = crypto_sign_ed25519_pk_to_curve25519( 31 | static_cast(derived_pk) + 1, ed25519); 32 | if (rc != 0) 33 | throw std::invalid_argument{"Failed to convert session_ed25519 to x25519 pubkey"}; 34 | if (derived_pk != id) 35 | throw std::invalid_argument{ 36 | "account_id/session_ed25519 mismatch: session_ed25519 does not convert to " 37 | "given account_id"}; 38 | } 39 | } else { 40 | std::memcpy(ed25519.data(), id.data() + 1, 32); 41 | } 42 | swarm = INVALID_SWARM_ID; 43 | } 44 | 45 | bool SwarmPubkey::update_swarm(const std::vector& swarm_ids) const { 46 | 47 | uint64_t closest; 48 | if (swarm_ids.size() == 0) 49 | closest = INVALID_SWARM_ID; 50 | else if (swarm_ids.size() == 1) 51 | closest = swarm_ids.front(); 52 | else { 53 | // Adapted from oxen-storage-server: 54 | 55 | // Find the right boundary, i.e. first swarm with swarm_id >= res 56 | auto right_it = std::lower_bound(swarm_ids.begin(), swarm_ids.end(), swarm_space); 57 | if (right_it == swarm_ids.end()) 58 | // res is > the top swarm_id, meaning it is big and in the wrapping space between 59 | // last and first elements. 60 | right_it = swarm_ids.begin(); 61 | 62 | // Our "left" is the one just before that (with wraparound, if right is the first swarm) 63 | auto left_it = std::prev(right_it == swarm_ids.begin() ? swarm_ids.end() : right_it); 64 | 65 | uint64_t dright = *right_it - swarm_space; 66 | uint64_t dleft = swarm_space - *left_it; 67 | 68 | closest = dright < dleft ? *right_it : *left_it; 69 | } 70 | 71 | if (closest != swarm) { 72 | swarm = closest; 73 | return true; 74 | } 75 | return false; 76 | } 77 | 78 | } // namespace spns 79 | -------------------------------------------------------------------------------- /spns/swarmpubkey.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include "bytes.hpp" 7 | 8 | namespace spns { 9 | 10 | inline constexpr uint64_t INVALID_SWARM_ID = std::numeric_limits::max(); 11 | 12 | struct SwarmPubkey { 13 | AccountID id; 14 | Ed25519PK ed25519; 15 | bool session_ed = false; // True if the ed25519 is different from the account id (i.e. for 16 | // Session X25519 pubkey accounts). 17 | uint64_t swarm_space; 18 | mutable uint64_t swarm; 19 | 20 | bool operator==(const SwarmPubkey& other) const { return id == other.id; } 21 | bool operator!=(const SwarmPubkey& other) const { return !(*this == other); } 22 | 23 | SwarmPubkey(AccountID account_id, std::optional ed, bool _skip_validation = false); 24 | 25 | bool update_swarm(const std::vector& swarm_ids) const; 26 | }; 27 | 28 | } // namespace spns 29 | 30 | namespace std { 31 | 32 | template <> 33 | struct hash { 34 | size_t operator()(const spns::SwarmPubkey& x) const { 35 | // A random chunk of the inside of the pubkey is already a good hash without 36 | // needing to otherwise hash the byte string 37 | static_assert( 38 | alignof(spns::SwarmPubkey) >= alignof(size_t) && 39 | offsetof(spns::SwarmPubkey, id) % sizeof(size_t) == 0); 40 | return *reinterpret_cast(x.id.data() + 16); 41 | } 42 | }; 43 | 44 | } // namespace std 45 | -------------------------------------------------------------------------------- /spns/utils.cpp: -------------------------------------------------------------------------------- 1 | #include "utils.hpp" 2 | 3 | #include 4 | 5 | extern "C" { 6 | #include "sys/resource.h" 7 | } 8 | 9 | namespace spns { 10 | 11 | namespace log = oxen::log; 12 | 13 | auto cat = log::Cat("utils"); 14 | 15 | std::vector split(std::string_view str, std::string_view delim, bool trim) { 16 | std::vector results; 17 | // Special case for empty delimiter: splits on each character boundary: 18 | if (delim.empty()) { 19 | results.reserve(str.size()); 20 | for (size_t i = 0; i < str.size(); i++) 21 | results.emplace_back(str.data() + i, 1); 22 | return results; 23 | } 24 | 25 | for (size_t pos = str.find(delim); pos != std::string_view::npos; pos = str.find(delim)) { 26 | if (!trim || !results.empty() || pos > 0) 27 | results.push_back(str.substr(0, pos)); 28 | str.remove_prefix(pos + delim.size()); 29 | } 30 | if (!trim || str.size()) 31 | results.push_back(str); 32 | else 33 | while (!results.empty() && results.back().empty()) 34 | results.pop_back(); 35 | return results; 36 | } 37 | 38 | std::vector split_any(std::string_view str, std::string_view delims, bool trim) { 39 | if (delims.empty()) 40 | return split(str, delims, trim); 41 | std::vector results; 42 | for (size_t pos = str.find_first_of(delims); pos != std::string_view::npos; 43 | pos = str.find_first_of(delims)) { 44 | if (!trim || !results.empty() || pos > 0) 45 | results.push_back(str.substr(0, pos)); 46 | size_t until = str.find_first_not_of(delims, pos + 1); 47 | if (until == std::string_view::npos) 48 | str.remove_prefix(str.size()); 49 | else 50 | str.remove_prefix(until); 51 | } 52 | if (!trim || str.size()) 53 | results.push_back(str); 54 | else 55 | while (!results.empty() && results.back().empty()) 56 | results.pop_back(); 57 | return results; 58 | } 59 | 60 | void fiddle_rlimit_nofile() { 61 | struct rlimit nofile {}; 62 | auto rc = getrlimit(RLIMIT_NOFILE, &nofile); 63 | if (rc != 0) { 64 | // log about failure 65 | } else if (nofile.rlim_cur < 10000 && nofile.rlim_cur < nofile.rlim_max) { 66 | auto new_lim = std::min(10000, nofile.rlim_max); 67 | log::warning(cat, "NOFILE limit is only {}; increasing to {}", nofile.rlim_cur, new_lim); 68 | nofile.rlim_cur = new_lim; 69 | rc = setrlimit(RLIMIT_NOFILE, &nofile); 70 | if (rc != 0) 71 | log::error( 72 | cat, 73 | "Failed to increase fd limit: {}; connections may fail!", 74 | std::strerror(rc)); 75 | } 76 | } 77 | 78 | static_assert(digits(0) == 1); 79 | static_assert(digits(9) == 1); 80 | static_assert(digits(10) == 2); 81 | static_assert(digits(99) == 2); 82 | static_assert(digits(100) == 3); 83 | 84 | } // namespace spns 85 | -------------------------------------------------------------------------------- /spns/utils.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | namespace spns { 11 | 12 | using steady_clock = std::chrono::steady_clock; 13 | using system_clock = std::chrono::system_clock; 14 | using steady_time = steady_clock::time_point; 15 | using system_time = system_clock::time_point; 16 | 17 | using namespace std::literals; 18 | 19 | using bstring = std::basic_string; 20 | using bstring_view = std::basic_string_view; 21 | 22 | using ustring = std::basic_string; 23 | using ustring_view = std::basic_string_view; 24 | 25 | inline std::string_view as_sv(bstring_view s) { 26 | return {reinterpret_cast(s.data()), s.size()}; 27 | } 28 | inline std::string copy_str(bstring_view s) { 29 | return std::string{as_sv(s)}; 30 | } 31 | 32 | inline bstring_view as_bsv(std::string_view s) { 33 | return {reinterpret_cast(s.data()), s.size()}; 34 | } 35 | 36 | inline ustring_view as_usv(std::string_view s) { 37 | return {reinterpret_cast(s.data()), s.size()}; 38 | } 39 | inline ustring_view as_usv(bstring_view s) { 40 | return {reinterpret_cast(s.data()), s.size()}; 41 | } 42 | 43 | // Can replace this with a `using namespace oxen::log::literals` if we start using oxen-logging 44 | namespace detail { 45 | 46 | // Internal implementation of _format that holds the format temporarily until the (...) operator 47 | // is invoked on it. This object cannot be moved, copied but only used ephemerally in-place. 48 | struct fmt_wrapper { 49 | private: 50 | std::string_view format; 51 | 52 | // Non-copyable and non-movable: 53 | fmt_wrapper(const fmt_wrapper&) = delete; 54 | fmt_wrapper& operator=(const fmt_wrapper&) = delete; 55 | fmt_wrapper(fmt_wrapper&&) = delete; 56 | fmt_wrapper& operator=(fmt_wrapper&&) = delete; 57 | 58 | public: 59 | constexpr explicit fmt_wrapper(const char* str, const std::size_t len) : format{str, len} {} 60 | 61 | /// Calling on this object forwards all the values to fmt::format, using the format string 62 | /// as provided during construction (via the "..."_format user-defined function). 63 | template 64 | auto operator()(T&&... args) && { 65 | return fmt::format(format, std::forward(args)...); 66 | } 67 | }; 68 | 69 | } // namespace detail 70 | 71 | inline detail::fmt_wrapper operator""_format(const char* str, size_t len) { 72 | return detail::fmt_wrapper{str, len}; 73 | } 74 | 75 | /// Splits a string on some delimiter string and returns a vector of string_view's pointing into the 76 | /// pieces of the original string. The pieces are valid only as long as the original string remains 77 | /// valid. Leading and trailing empty substrings are not removed. If delim is empty you get back a 78 | /// vector of string_views each viewing one character. If `trim` is true then leading and trailing 79 | /// empty values will be suppressed. 80 | /// 81 | /// auto v = split("ab--c----de", "--"); // v is {"ab", "c", "", "de"} 82 | /// auto v = split("abc", ""); // v is {"a", "b", "c"} 83 | /// auto v = split("abc", "c"); // v is {"ab", ""} 84 | /// auto v = split("abc", "c", true); // v is {"ab"} 85 | /// auto v = split("-a--b--", "-"); // v is {"", "a", "", "b", "", ""} 86 | /// auto v = split("-a--b--", "-", true); // v is {"a", "", "b"} 87 | /// 88 | std::vector split( 89 | std::string_view str, std::string_view delim, bool trim = false); 90 | 91 | /// Splits a string on any 1 or more of the given delimiter characters and returns a vector of 92 | /// string_view's pointing into the pieces of the original string. If delims is empty this works 93 | /// the same as split(). `trim` works like split (suppresses leading and trailing empty string 94 | /// pieces). 95 | /// 96 | /// auto v = split_any("abcdedf", "dcx"); // v is {"ab", "e", "f"} 97 | std::vector split_any( 98 | std::string_view str, std::string_view delims, bool trim = false); 99 | 100 | // Returns unix timestamp seconds for the given system clock time 101 | inline int64_t unix_timestamp(system_time t) { 102 | return std::chrono::duration_cast(t.time_since_epoch()).count(); 103 | } 104 | 105 | inline bool starts_with(std::string_view string, std::string_view prefix) { 106 | return string.substr(0, prefix.size()) == prefix; 107 | } 108 | 109 | inline bool ends_with(std::string_view string, std::string_view suffix) { 110 | return string.size() >= suffix.size() && string.substr(string.size() - suffix.size()) == suffix; 111 | } 112 | 113 | // Returns unix timestamp seconds for the current time. 114 | inline int64_t unix_timestamp() { 115 | return unix_timestamp(system_clock::now()); 116 | } 117 | 118 | /// Parses an integer of some sort from a string, requiring that the entire string be consumed 119 | /// during parsing. Return false if parsing failed, sets `value` and returns true if the entire 120 | /// string was consumed. 121 | template 122 | bool parse_int(const std::string_view str, T& value, int base = 10) { 123 | T tmp; 124 | auto* strend = str.data() + str.size(); 125 | auto [p, ec] = std::from_chars(str.data(), strend, tmp, base); 126 | if (ec != std::errc() || p != strend) 127 | return false; 128 | value = tmp; 129 | return true; 130 | } 131 | 132 | void fiddle_rlimit_nofile(); 133 | 134 | constexpr int digits(size_t val) { 135 | int i = 0; 136 | do { 137 | ++i; 138 | val /= 10; 139 | } while (val); 140 | return i; 141 | } 142 | 143 | } // namespace spns 144 | -------------------------------------------------------------------------------- /spns/web.py: -------------------------------------------------------------------------------- 1 | import flask 2 | from . import config 3 | import coloredlogs 4 | import uwsgi 5 | import oxenmq 6 | from uwsgidecorators import postfork 7 | 8 | app = flask.Flask(__name__) 9 | coloredlogs.install( 10 | milliseconds=True, isatty=True, logger=app.logger, level=config.core_logger.get_level() 11 | ) 12 | 13 | # Monkey-patch app.get/post/etc. for Flask <2 compatibility; this has to be before the imports, 14 | # below, because they depend on this existing. 15 | if not hasattr(flask.Flask, "post"): 16 | 17 | def _add_route_shortcut(on, name): 18 | def meth(self, rule: str, **options): 19 | return self.route(rule, methods=[name.upper()], **options) 20 | 21 | setattr(on, name, meth) 22 | 23 | for method in ("get", "post", "put", "delete", "patch"): 24 | _add_route_shortcut(flask.Flask, method) 25 | _add_route_shortcut(flask.Blueprint, method) 26 | 27 | omq = None 28 | hivemind = None 29 | 30 | 31 | @postfork 32 | def start_oxenmq(): 33 | if uwsgi.mule_id() != 0: 34 | # Mules manage their own connections 35 | return 36 | 37 | global omq, hivemind 38 | 39 | app.logger.info(f"Starting oxenmq connection from web worker {uwsgi.worker_id()}") 40 | 41 | omq = oxenmq.OxenMQ() 42 | omq.start() 43 | app.logger.info("Started web worker, connecting to hivemind") 44 | 45 | hivemind = omq.connect_remote(oxenmq.Address(config.config.hivemind_sock)) 46 | 47 | 48 | # Load components that depend on our `app` for registering themselves: 49 | from . import onion_request 50 | from . import register 51 | -------------------------------------------------------------------------------- /systemd/spns-hivemind.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Session Push Notification Server -- central push handler (hivemind) 3 | After=network-online.target 4 | PartOf=spns.target 5 | 6 | [Service] 7 | User=push 8 | Group=_loki 9 | Type=notify 10 | WatchdogSec=1min 11 | WorkingDirectory=/home/push/session-push-notification-server 12 | LimitNOFILE=16384 13 | Restart=always 14 | RestartSec=5s 15 | ExecStart=/usr/bin/python3 -mspns.hivemind 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /systemd/spns-notifier@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Session Push Notification Server -- %i notifier 3 | After=network-online.target 4 | Wants=spns-hivemind.service 5 | After=spns-hivemind.service 6 | PartOf=spns.target 7 | 8 | [Service] 9 | User=push 10 | Group=_loki 11 | Type=notify 12 | WatchdogSec=1min 13 | WorkingDirectory=/home/push/session-push-notification-server 14 | Restart=always 15 | RestartSec=5s 16 | ExecStart=/usr/bin/python3 -mspns.notifiers.%i 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /systemd/spns.target: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Session Push Notification Server services 3 | -------------------------------------------------------------------------------- /tests/test_const.py: -------------------------------------------------------------------------------- 1 | from utils import DeviceType 2 | 3 | TEST_DEVICE_TYPE = DeviceType.Android 4 | TEST_SESSION_ID = 'test_session_id' 5 | TEST_SESSION_ID_1 = 'test_session_id_1' 6 | TEST_TOKEN_0 = 'test_token_0' 7 | TEST_TOKEN_1 = 'test_token_1' 8 | TEST_CLOSED_GROUP_ID = 'test_closed_group_id' 9 | TEST_DATA = 'test_data_bla_bla...' 10 | -------------------------------------------------------------------------------- /tests/test_databaseHelperV2.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from model.databaseModelV2 import * 3 | from tools.databaseHelperV2 import DatabaseHelperV2 4 | from test_const import * 5 | from model.pushNotificationStats import * 6 | 7 | 8 | tests_cases = ['populate_cache', 9 | 'flush', 10 | 'statistics_data'] 11 | 12 | 13 | class DatabaseHelperV2Tests(unittest.TestCase): 14 | def setUp(self): 15 | self.databaseHelper = DatabaseHelperV2() 16 | 17 | def tearDown(self): 18 | pass 19 | 20 | def test_0_populate_cache(self): 21 | self.databaseHelper.populate_cache() 22 | 23 | self.assertGreater(len(self.databaseHelper.device_cache.items()), 0) 24 | self.assertGreater(len(self.databaseHelper.token_device_mapping.items()), 0) 25 | self.assertGreater(len(self.databaseHelper.closed_group_cache.items()), 0) 26 | 27 | def test_1_flush(self): 28 | test_device = Device() 29 | test_device.session_id = TEST_SESSION_ID 30 | test_device.add_token(Device.Token(TEST_TOKEN_0, DeviceType.Unknown)) 31 | test_device.save_to_cache(self.databaseHelper) 32 | 33 | test_device_in_cache = self.databaseHelper.get_device(TEST_SESSION_ID) 34 | self.assertFalse(test_device_in_cache is None) 35 | 36 | test_closed_group = ClosedGroup() 37 | test_closed_group.closed_group_id = TEST_CLOSED_GROUP_ID 38 | test_closed_group.add_member(TEST_SESSION_ID) 39 | test_closed_group.save_to_cache(self.databaseHelper) 40 | 41 | test_closed_group_in_cache = self.databaseHelper.get_closed_group(TEST_CLOSED_GROUP_ID) 42 | self.assertFalse(test_closed_group_in_cache is None) 43 | 44 | self.databaseHelper.flush() 45 | self.assertFalse(test_device_in_cache.needs_to_be_updated) 46 | self.assertFalse(test_closed_group_in_cache.needs_to_be_updated) 47 | 48 | self.databaseHelper.device_cache.clear() 49 | self.databaseHelper.closed_group_cache.clear() 50 | self.databaseHelper.populate_cache() 51 | 52 | test_device_in_db = self.databaseHelper.get_device(TEST_SESSION_ID) 53 | self.assertFalse(test_device_in_db is None) 54 | 55 | test_closed_group_in_db = self.databaseHelper.get_closed_group(TEST_CLOSED_GROUP_ID) 56 | self.assertFalse(test_closed_group_in_db is None) 57 | 58 | def test_2_statistics_data(self): 59 | stats_data = PushNotificationStats() 60 | stats_data.increment_ios_pn(1) 61 | stats_data.increment_android_pn(1) 62 | stats_data.increment_total_message(1) 63 | stats_data.increment_closed_group_message(1) 64 | stats_data.increment_untracked_message(1) 65 | stats_data.increment_deduplicated_one_on_one_message(1) 66 | 67 | statistics_data = self.databaseHelper.get_stats_data(None, None) 68 | total_columns_before = len(statistics_data[PushNotificationStats.ResponseKey.DATA]) 69 | self.databaseHelper.store_stats_data(stats_data) 70 | statistics_data = self.databaseHelper.get_stats_data(None, None) 71 | total_columns_after = len(statistics_data[PushNotificationStats.ResponseKey.DATA]) 72 | 73 | self.assertEqual(total_columns_before + 1, total_columns_after) 74 | 75 | def test_3_adding_duplicated_token(self): 76 | test_device = Device() 77 | test_device.session_id = TEST_SESSION_ID 78 | test_device.add_token(Device.Token(TEST_TOKEN_0, DeviceType.Unknown)) 79 | test_device.save_to_cache(self.databaseHelper) 80 | self.databaseHelper.flush() 81 | 82 | test_device_in_cache = self.databaseHelper.get_device(TEST_SESSION_ID) 83 | test_device_in_cache.add_token(Device.Token(TEST_TOKEN_0, DeviceType.Unknown)) 84 | self.assertFalse(test_device_in_cache.needs_to_be_updated) 85 | 86 | 87 | if __name__ == '__main__': 88 | unittest.main() 89 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from test_databaseHelperV2 import tests_cases as database_helper_test_cases 3 | from test_databaseHelperV2 import DatabaseHelperV2Tests 4 | from test_pushNotificationHandler import tests_cases as push_notification_handler_test_cases 5 | from test_pushNotificationHandler import PushNotificationHandlerTests 6 | from test_server import tests_cases as server_test_cases 7 | from test_server import ServerTests 8 | 9 | 10 | def suite(): 11 | test_suite = unittest.TestSuite() 12 | 13 | index = 0 14 | for test_case in database_helper_test_cases: 15 | test_suite.addTest(DatabaseHelperV2Tests(f'test_{index}_{test_case}')) 16 | index += 1 17 | 18 | index = 0 19 | for test_case in push_notification_handler_test_cases: 20 | test_suite.addTest(PushNotificationHandlerTests(f'test_{index}_{test_case}')) 21 | index += 1 22 | 23 | index = 0 24 | for test_case in server_test_cases: 25 | test_suite.addTest(ServerTests(f'test_{index}_{test_case}')) 26 | index += 1 27 | 28 | return test_suite 29 | 30 | 31 | if __name__ == '__main__': 32 | runner = unittest.TextTestRunner(verbosity=2) 33 | runner.run(suite()) 34 | -------------------------------------------------------------------------------- /tests/test_pushNotificationHandler.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import asyncio 3 | from test_const import * 4 | from tools.databaseHelperV2 import DatabaseHelperV2 5 | from model.databaseModelV2 import Device 6 | from tools.pushNotificationHandler import PushNotificationHelperV2 7 | 8 | tests_cases = ['register', 9 | 'unregister', 10 | 'subscribe_closed_group', 11 | 'unsubscribe_closed_group', 12 | 'send_push_notification', 13 | 'handle_push_fail'] 14 | 15 | 16 | class PushNotificationHandlerTests(unittest.TestCase): 17 | def setUp(self): 18 | self.database_helper = DatabaseHelperV2() 19 | self.PN_helper_v2 = PushNotificationHelperV2() 20 | 21 | def tearDown(self): 22 | self.database_helper.flush() 23 | 24 | def test_0_register(self): 25 | self.PN_helper_v2.register(TEST_TOKEN_0, TEST_SESSION_ID, TEST_DEVICE_TYPE) 26 | test_device_in_cache = self.database_helper.get_device(TEST_SESSION_ID) 27 | self.assertFalse(test_device_in_cache is None) 28 | self.assertEqual(self.PN_helper_v2.push_fails[TEST_TOKEN_0], 0) 29 | self.assertTrue(Device.Token(TEST_TOKEN_0, TEST_DEVICE_TYPE) in test_device_in_cache.tokens) 30 | self.assertTrue(TEST_TOKEN_0 in self.database_helper.token_device_mapping.keys()) 31 | self.assertEqual(self.database_helper.token_device_mapping[TEST_TOKEN_0], test_device_in_cache) 32 | 33 | self.PN_helper_v2.register(TEST_TOKEN_1, TEST_SESSION_ID, TEST_DEVICE_TYPE) 34 | test_device_in_cache = self.database_helper.get_device(TEST_SESSION_ID) 35 | self.assertTrue(Device.Token(TEST_TOKEN_1, TEST_DEVICE_TYPE) in test_device_in_cache.tokens) 36 | self.assertEqual(len(test_device_in_cache.tokens), 2) 37 | self.assertTrue(TEST_TOKEN_1 in self.database_helper.token_device_mapping.keys()) 38 | self.assertEqual(self.database_helper.token_device_mapping[TEST_TOKEN_1], test_device_in_cache) 39 | 40 | self.PN_helper_v2.push_fails[TEST_TOKEN_0] += 3 41 | self.PN_helper_v2.register(TEST_TOKEN_0, TEST_SESSION_ID, TEST_DEVICE_TYPE) 42 | test_device_in_cache = self.database_helper.get_device(TEST_SESSION_ID) 43 | self.assertEqual(len(test_device_in_cache.tokens), 2) 44 | self.assertEqual(self.PN_helper_v2.push_fails[TEST_TOKEN_0], 0) 45 | 46 | def test_1_unregister(self): 47 | test_session_id = self.PN_helper_v2.remove_device_token(TEST_TOKEN_1) 48 | test_device_in_cache = self.database_helper.get_device(TEST_SESSION_ID) 49 | self.assertEqual(test_session_id, TEST_SESSION_ID) 50 | self.assertEqual(len(test_device_in_cache.tokens), 1) 51 | self.assertFalse(Device.Token(TEST_TOKEN_1, TEST_DEVICE_TYPE) in test_device_in_cache.tokens) 52 | self.assertTrue(self.PN_helper_v2.push_fails.get(TEST_TOKEN_1) is None) 53 | self.assertFalse(TEST_TOKEN_1 in self.database_helper.token_device_mapping.keys()) 54 | self.assertTrue(self.database_helper.token_device_mapping.get(TEST_TOKEN_1) is None) 55 | 56 | def test_2_subscribe_closed_group(self): 57 | self.PN_helper_v2.subscribe_closed_group(TEST_CLOSED_GROUP_ID, TEST_SESSION_ID) 58 | test_closed_group_in_cache = self.database_helper.get_closed_group(TEST_CLOSED_GROUP_ID) 59 | self.assertFalse(test_closed_group_in_cache is None) 60 | self.assertTrue(TEST_SESSION_ID in test_closed_group_in_cache.members) 61 | 62 | self.PN_helper_v2.subscribe_closed_group(TEST_CLOSED_GROUP_ID, TEST_SESSION_ID_1) 63 | test_closed_group_in_cache = self.database_helper.get_closed_group(TEST_CLOSED_GROUP_ID) 64 | self.assertTrue(TEST_SESSION_ID_1 in test_closed_group_in_cache.members) 65 | self.assertEqual(len(test_closed_group_in_cache.members), 2) 66 | 67 | def test_3_unsubscribe_closed_group(self): 68 | self.PN_helper_v2.unsubscribe_closed_group(TEST_CLOSED_GROUP_ID, TEST_SESSION_ID_1) 69 | test_closed_group_in_cache = self.database_helper.get_closed_group(TEST_CLOSED_GROUP_ID) 70 | self.assertEqual(len(test_closed_group_in_cache.members), 1) 71 | self.assertFalse(TEST_SESSION_ID_1 in test_closed_group_in_cache.members) 72 | 73 | def test_4_send_push_notification(self): 74 | test_message = {'send_to': TEST_SESSION_ID, 75 | 'data': TEST_DATA} 76 | self.PN_helper_v2.add_message_to_queue(test_message) 77 | self.assertTrue(self.PN_helper_v2.message_queue.not_empty) 78 | 79 | loop = asyncio.get_event_loop() 80 | coroutine = self.PN_helper_v2.send_push_notification() 81 | loop.run_until_complete(coroutine) 82 | self.assertEqual(self.PN_helper_v2.stats_data.notification_counter_android, 1) 83 | 84 | test_closed_group_message = {'send_to': TEST_CLOSED_GROUP_ID, 85 | 'data': TEST_DATA} 86 | self.PN_helper_v2.add_message_to_queue(test_closed_group_message) 87 | loop = asyncio.get_event_loop() 88 | coroutine = self.PN_helper_v2.send_push_notification() 89 | loop.run_until_complete(coroutine) 90 | self.assertEqual(self.PN_helper_v2.stats_data.closed_group_messages, 1) 91 | self.assertEqual(self.PN_helper_v2.stats_data.notification_counter_android, 2) 92 | 93 | def test_5_handle_push_fail(self): 94 | self.PN_helper_v2.register(TEST_TOKEN_0, TEST_SESSION_ID, TEST_DEVICE_TYPE) 95 | self.PN_helper_v2.handle_fail_result(TEST_TOKEN_0, '') 96 | self.assertEqual(self.PN_helper_v2.push_fails[TEST_TOKEN_0], 1) 97 | 98 | for i in range(5): 99 | self.PN_helper_v2.handle_fail_result(TEST_TOKEN_0, '') 100 | test_device_in_cache = self.database_helper.get_device(TEST_SESSION_ID) 101 | self.assertFalse(Device.Token(TEST_TOKEN_0, TEST_DEVICE_TYPE) in test_device_in_cache.tokens) 102 | 103 | 104 | if __name__ == '__main__': 105 | unittest.main() 106 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from test_const import * 3 | from server import * 4 | 5 | 6 | tests_cases = ['lsrpc', 7 | 'get_statistics_data', 8 | 'register', 9 | 'notify', 10 | 'unregister', 11 | 'subscribe_closed_group', 12 | 'unsubscribe_closed_group'] 13 | 14 | 15 | class ServerTests(unittest.TestCase): 16 | def setUp(self): 17 | self.app = app.test_client() 18 | self.database_helper = DatabaseHelperV2() 19 | self.notification_helper = PushNotificationHelperV2() 20 | 21 | def tearDown(self): 22 | pass 23 | 24 | def test_0_lsrpc(self): 25 | body = {} 26 | body_as_string = json.dumps(body) 27 | ciphertext = b'...' 28 | ciphertext_length = len(ciphertext).to_bytes(4, "little") 29 | data = ciphertext_length + ciphertext + body_as_string.encode('utf-8') 30 | response = self.app.post('/loki/v2/lsrpc', data=data) 31 | self.assertEqual(response.status_code, 400) 32 | 33 | def test_1_get_statistics_data(self): 34 | header = {'Authorization': 'Basic dGVzdDpebmZlK0x2KzJkLTJXIUI4QStFLXJkeV5VSm1xNSM4RA==', 35 | 'Content-Type': 'application/json'} 36 | params = {} 37 | response = self.app.post('/get_statistics_data', headers=header, json=params) 38 | self.assertEqual(response.status_code, 200) 39 | 40 | def test_2_register(self): 41 | args = {HTTP.RegistrationRequest.TOKEN: TEST_TOKEN_0, 42 | HTTP.RegistrationRequest.PUBKEY: TEST_SESSION_ID} 43 | register_v2(args) 44 | test_device_in_cache = self.database_helper.device_cache.get(TEST_SESSION_ID) 45 | self.assertTrue(TEST_TOKEN_0 in test_device_in_cache.tokens) 46 | 47 | def test_3_notify(self): 48 | args = {HTTP.NotificationRequest.SEND_TO: TEST_SESSION_ID, 49 | HTTP.NotificationRequest.DATA: TEST_DATA} 50 | notify(args) 51 | message_in_queue = self.notification_helper.message_queue.get() 52 | self.assertEqual(args, message_in_queue) 53 | 54 | def test_4_unregister(self): 55 | args = {HTTP.RegistrationRequest.TOKEN: TEST_TOKEN_0} 56 | unregister(args) 57 | test_device_in_cache = self.database_helper.device_cache.get(TEST_SESSION_ID) 58 | self.assertFalse(TEST_TOKEN_0 in test_device_in_cache.tokens) 59 | 60 | def test_5_subscribe_closed_group(self): 61 | args = {HTTP.SubscriptionRequest.CLOSED_GROUP: TEST_CLOSED_GROUP_ID, 62 | HTTP.SubscriptionRequest.PUBKEY: TEST_SESSION_ID} 63 | subscribe_closed_group(args) 64 | test_closed_group_in_cache = self.database_helper.closed_group_cache.get(TEST_CLOSED_GROUP_ID) 65 | self.assertTrue(TEST_SESSION_ID in test_closed_group_in_cache.members) 66 | 67 | def test_6_unsubscribe_closed_group(self): 68 | args = {HTTP.SubscriptionRequest.CLOSED_GROUP: TEST_CLOSED_GROUP_ID, 69 | HTTP.SubscriptionRequest.PUBKEY: TEST_SESSION_ID} 70 | unsubscribe_closed_group(args) 71 | test_closed_group_in_cache = self.database_helper.closed_group_cache.get(TEST_CLOSED_GROUP_ID) 72 | self.assertFalse(TEST_SESSION_ID in test_closed_group_in_cache.members) 73 | 74 | 75 | if __name__ == '__main__': 76 | unittest.main() 77 | -------------------------------------------------------------------------------- /uwsgi-spns.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | chdir = /path/to/session-push-notification-server 3 | socket = spns.wsgi 4 | chmod-socket = 666 5 | plugins = python3,logfile 6 | manage-script-name = true 7 | logger = file:logfile=/path/to/session-push-notification-server/spns.log,maxsize=100000000,backupname=/path/to/session-push-notification-server/spns.log.old 8 | 9 | # Because requests block while waiting for the hivemind to process the request we run with lots of 10 | # thread workers available that can deal with lots of concurrent pending connections in case the 11 | # hivemind gets momentarily busy handling requests and/or notifications. (This would be a good use 12 | # for async handling, but uwsgi doesn't currently support that). 13 | enable-threads = true 14 | threads = 16 15 | 16 | # This is the main handler for front-end "app" requests: 17 | mount = /=spns.web:app 18 | 19 | # Alongside the web front-end interface we *can* run the backend workers (which do most of the work) 20 | # as "mules", so that all the processes are managed by uwsgi. 21 | # 22 | # Alternatively, you can run hivemind as notifiers as systemd services, which is a little more 23 | # flexible. 24 | # 25 | # Choose an approach: if using systemd then install the systemd/* files and activate the services 26 | # via systemd, keeping the following mules commented out. 27 | # If using mules and putting everything under uwsgi then *don't* install the systemd files and 28 | # uncomment the hivemind and any desired notifiers here. 29 | 30 | ### Hivemind 31 | # This is the main "hivemind" mule that is the central business component of the PN server. 32 | #mule = spns.hivemind:run 33 | 34 | ### Notifiers 35 | # Apple APNS push notifier; required if you want to support APNS notifications: 36 | #mule = spns.notifiers.apns:run 37 | # Google firebase push notification server for Google API android device notifications: 38 | #mule = spns.notifiers.firebase:run 39 | 40 | # Dummy notifier, normally disabled. This notifier does nothing aside from logging would-be 41 | # notifications. Leave disabled when not developing/debugging. 42 | #mule = spns.notifiers.dummy:run 43 | --------------------------------------------------------------------------------