commit 1a65ba85867bb31ed0a0785a9395f8187d1d2830 parent ee271578e49912f80d5a458f1a5473c53d9819a0 Author: Antoine A <> Date: Wed, 1 Apr 2026 18:48:40 +0200 apns-relay: init Diffstat:
40 files changed, 1717 insertions(+), 14 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -9,7 +9,9 @@ keys.json Cargo.lock debian/taler-magnet-bank debian/taler-cyclos +debian/taler-apns-relay debian/files debian/*.substvars debian/*debhelper* -postgres-language-server.jsonc -\ No newline at end of file +postgres-language-server.jsonc +*.p8 +\ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "common/http-client", "taler-magnet-bank", "taler-cyclos", + "taler-apns-relay", ] [workspace.package] @@ -58,5 +59,9 @@ base64 = "0.22" owo-colors = "4.2.3" aws-lc-rs = "1.15" compact_str = { version = "0.9.0", features = ["serde", "sqlx-postgres"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } +hyper-rustls = { version = "0.27", features = ["http2"] } rand = { version = "0.10" } regex = { version = "1" } +rustls = "0.23" +http = "1.4" diff --git a/Makefile b/Makefile @@ -13,7 +13,7 @@ all: build .PHONY: build build: - cargo build --release --bin taler-magnet-bank --bin taler-cyclos + cargo build --release --bin taler-magnet-bank --bin taler-cyclos --bin taler-apns-relay .PHONY: install-nobuild-files install-nobuild-files: @@ -27,6 +27,11 @@ install-nobuild-files: install -m 644 -D -t $(share_dir)/taler-cyclos/sql taler-cyclos/db/cyclos*.sql install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/taler-cyclos.1 install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/taler-cyclos.conf.5 + install -m 644 -D -t $(share_dir)/taler-apns-relay/config.d taler-apns-relay/apns-relay.conf + install -m 644 -D -t $(share_dir)/taler-apns-relay/sql common/taler-common/db/versioning.sql + install -m 644 -D -t $(share_dir)/taler-apns-relay/sql taler-apns-relay/db/apns-relay*.sql + install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/taler-apns-relay.1 + install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/taler-apns-relay.conf.5 .PHONY: install install: build install-nobuild-files @@ -34,6 +39,8 @@ install: build install-nobuild-files install -D -t $(bin_dir) target/release/taler-magnet-bank install -D -t $(bin_dir) contrib/taler-cyclos-dbconfig install -D -t $(bin_dir) target/release/taler-cyclos + install -D -t $(bin_dir) contrib/taler-apns-relay-dbconfig + install -D -t $(bin_dir) target/release/taler-apns-relay .PHONY: check check: install-nobuild-files diff --git a/common/http-client/Cargo.toml b/common/http-client/Cargo.toml @@ -24,13 +24,14 @@ anyhow.workspace = true base64.workspace = true hyper.workspace = true tokio.workspace = true +hyper-util.workspace = true +hyper-rustls.workspace = true +rustls.workspace = true +http-body-util.workspace = true +http.workspace = true tokio-util = { version = "0.7.17", default-features = false, features = [ "codec", "io", ] } futures-util = { version = "0.3", default-features = false } -http-body-util = { version = "0.1" } -hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } -hyper-rustls = { version = "0.27", features = ["http2"] } -rustls = "0.23" -http = "1.4" + diff --git a/common/taler-common/src/config.rs b/common/taler-common/src/config.rs @@ -23,6 +23,7 @@ use std::{ time::Duration, }; +use compact_str::CompactString; use indexmap::IndexMap; use jiff::SignedDuration; use url::Url; @@ -798,6 +799,11 @@ impl<'cfg, 'arg> Section<'cfg, 'arg> { self.value("string", option, |it| Ok::<_, &str>(it.to_owned())) } + /** Access [option] as compact str */ + pub fn cstr(&self, option: &'arg str) -> Value<'arg, CompactString> { + self.value("string", option, |it| Ok::<_, CompactString>(it.into())) + } + /** Access [option] as path */ pub fn path(&self, option: &'arg str) -> Value<'arg, String> { self.value("path", option, |it| self.config.pathsub(it, 0)) diff --git a/contrib/taler-apns-relay-dbconfig b/contrib/taler-apns-relay-dbconfig @@ -0,0 +1,167 @@ +#!/bin/bash +# This file is part of GNU TALER. +# Copyright (C) 2025 Taler Systems SA +# +# TALER is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free Software +# Foundation; either version 2.1, or (at your option) any later version. +# +# TALER is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along with +# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +# +# @author Antoine d'Aligny + +# Error checking on +set -eu + +# 1 is true, 0 is false +RESET_DB=0 +FORCE_PERMS=0 +SKIP_INIT=0 +DBUSER="taler-apns-relay-httpd" +DBGROUP="taler-apns-relay-db" +CFGFILE="/etc/taler-apns-relay/taler-apns-relay.conf" + +# Parse command-line options +while getopts 'c:g:hprsu:' OPTION; do + case "$OPTION" in + c) + CFGFILE="$OPTARG" + ;; + g) + DBGROUP="$OPTARG" + ;; + h) + echo 'Supported options:' + echo " -c FILENAME -- use configuration FILENAME (default: $CFGFILE)" + echo " -g GROUP -- taler-apns-relay to be run by GROUP (default: $DBGROUP)" + echo " -h -- print this help text" + echo " -r -- reset database (dangerous)" + echo " -p -- force permission setup even without database initialization" + echo " -s -- skip database initialization" + echo " -u USER -- taler-apns-relay to be run by USER (default: $DBUSER)" + exit 0 + ;; + p) + FORCE_PERMS="1" + ;; + r) + RESET_DB="1" + ;; + s) + SKIP_INIT="1" + ;; + u) + DBUSER="$OPTARG" + ;; + ?) + echo "Unrecognized command line option '$OPTION'" 1 &>2 + exit 1 + ;; + esac +done + +function exit_fail() { + echo "$@" >&2 + exit 1 +} + +if ! id postgres >/dev/null; then + exit_fail "Could not find 'postgres' user. Please install Postgresql first" +fi + +if ! taler-apns-relay --version 2>/dev/null; then + exit_fail "Required 'taler-apns-relay' not found. Please fix your installation." +fi + +if [ "$(id -u)" -ne 0 ]; then + exit_fail "This script must be run as root" +fi + +# Check OS users exist +if ! id "$DBUSER" >/dev/null; then + exit_fail "Could not find '$DBUSER' user. Please set it up first" +fi + +# Create DB user matching OS user name +echo "Setting up database user '$DBUSER'." 1>&2 +if ! sudo -i -u postgres createuser "$DBUSER" 2>/dev/null; then + echo "Database user '$DBUSER' already existed. Continuing anyway." 1>&2 +fi + +# Check database name +DBPATH=$(taler-apns-relay -c "$CFGFILE" config get apns-relaydb-postgres CONFIG) +if ! echo "$DBPATH" | grep "postgres://" >/dev/null; then + exit_fail "Invalid database configuration value '$DBPATH'." 1>&2 +fi +DBNAME=$(echo "$DBPATH" | sed -e "s/postgres:\/\/.*\///" -e "s/?.*//") + +# Reset database +if sudo -i -u postgres psql "$DBNAME" </dev/null 2>/dev/null; then + if [ 1 = "$RESET_DB" ]; then + echo "Deleting existing database '$DBNAME'." 1>&2 + if ! sudo -i -u postgres dropdb "$DBNAME"; then + exit_fail "Failed to delete existing database '$DBNAME'" + fi + DO_CREATE=1 + else + echo "Database '$DBNAME' already exists, continuing anyway." + DO_CREATE=0 + fi +else + DO_CREATE=1 +fi + +# Create database +if [ 1 = "$DO_CREATE" ]; then + echo "Creating database '$DBNAME'." 1>&2 + if ! sudo -i -u postgres createdb -O "$DBUSER" "$DBNAME"; then + exit_fail "Failed to create database '$DBNAME'" + fi +fi + +# Run dbinit +if [ 0 = "$SKIP_INIT" ]; then + echo "Initialize database schema" + if ! sudo -u "$DBUSER" taler-apns-relay dbinit -c "$CFGFILE"; then + exit_fail "Failed to initialize database schema" + fi +fi + +# Set permission for group user +if [ 0 = "$SKIP_INIT" ] || [ 1 = "$FORCE_PERMS" ]; then + # Create DB group matching OS group name + echo "Setting up database group '$DBGROUP'." 1>&2 + if ! sudo -i -u postgres createuser "$DBGROUP" 2>/dev/null; then + echo "Database group '$DBGROUP' already existed. Continuing anyway." 1>&2 + fi + if ! sudo -i -u postgres psql "$DBNAME" <<-EOF + GRANT ALL ON SCHEMA apns_relay TO "$DBGROUP"; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA apns_relay TO "$DBGROUP"; +EOF + then + exit_fail "Failed to grant access to '$DBGROUP'." + fi + + # Update group users rights + DB_GRP="$(getent group "$DBGROUP" | sed -e "s/.*://g" -e "s/,/ /g")" + echo "Initializing permissions for '$DB_GRP' users." 1>&2 + for GROUPIE in $DB_GRP; do + if [ "$GROUPIE" != "$DBUSER" ]; then + if ! sudo -i -u postgres createuser "$GROUPIE" 2>/dev/null; then + echo "Database user '$GROUPIE' already existed. Continuing anyway." 1>&2 + fi + fi + + if ! echo "GRANT \"$DBGROUP\" TO \"$GROUPIE\"" | + sudo -i -u postgres psql "$DBNAME"; then + exit_fail "Failed to make '$GROUPIE' part of '$DBGROUP' db group." + fi + done +fi + +echo "Database configuration finished." 1>&2 diff --git a/debian/control b/debian/control @@ -25,4 +25,12 @@ Depends: ${misc:Depends}, ${shlibs:Depends} Recommends: nginx | apache2 | httpd, postgresql (>= 14.0) -Description: GNU Taler adapter for Cyclos -\ No newline at end of file +Description: GNU Taler adapter for Cyclos + +Package: taler-apns-relay +Architecture: any +Depends: ${misc:Depends}, ${shlibs:Depends} +Recommends: + nginx | apache2 | httpd, + postgresql (>= 14.0) +Description: GNU Taler APNs relay +\ No newline at end of file diff --git a/debian/etc/apache2/sites-available/taler-apns-relay.conf b/debian/etc/apache2/sites-available/taler-apns-relay.conf @@ -0,0 +1,22 @@ +# Make sure to enable the following Apache modules before +# integrating this into your configuration: +# +# a2enmod proxy +# a2enmod proxy_http +# a2enmod headers +# +# NOTE: +# - consider to adjust the location +# - consider putting all this into a VirtualHost +# - strongly consider setting up TLS support +# +# For all of the above, please read the respective +# Apache documentation. +# +<Location "/taler-apns-relay/"> + ProxyPass "unix:/var/run/taler-apns-relay/httpd/apns-relay-http.sock|http://example.com/" + + # NOTE: + # - Uncomment this line if you use TLS/HTTPS + RequestHeader add "X-Forwarded-Proto" "https" +</Location> diff --git a/debian/etc/nginx/sites-available/taler-apns-relay b/debian/etc/nginx/sites-available/taler-apns-relay @@ -0,0 +1,31 @@ +server { + # NOTE: + # - urgently consider configuring TLS instead + # - maybe keep a forwarder from HTTP to HTTPS + listen 80; + + # NOTE: + # - Comment out this line if you have no IPv6 + listen [::]:80; + + # NOTE: + # - replace with your actual server name + server_name localhost; + + access_log /var/log/nginx/taler-apns-relay.log; + error_log /var/log/nginx/taler-apns-relay.err; + + location /taler-apns-relay/ { + proxy_pass http://unix:/var/run/taler-apns-relay/httpd/apns-relay-http.sock:/; + proxy_redirect off; + proxy_set_header Host $host; + + # NOTE: + # - put your actual DNS name here + proxy_set_header X-Forwarded-Host "localhost"; + + # NOTE: + # - uncomment the following line if you are using HTTPS + # proxy_set_header X-Forwarded-Proto "https"; + } +} +\ No newline at end of file diff --git a/debian/etc/taler-apns-relay/conf.d/apns-relay-httpd.conf b/debian/etc/taler-apns-relay/conf.d/apns-relay-httpd.conf @@ -0,0 +1,4 @@ +# Configuration the apns relay worker REST API. + +[apns-relay-httpd] +SERVE = systemd diff --git a/debian/etc/taler-apns-relay/conf.d/apns-relay-system.conf b/debian/etc/taler-apns-relay/conf.d/apns-relay-system.conf @@ -0,0 +1,5 @@ +# Configuration for system aspects of the apns relay. + +# Read secret sections into configuration, but only +# if we have permission to do so. +@inline-secret@ apns-relaydb-postgres ../secrets/apns-relay-db.secret.conf diff --git a/debian/etc/taler-apns-relay/conf.d/apns-relay-worker.conf b/debian/etc/taler-apns-relay/conf.d/apns-relay-worker.conf @@ -0,0 +1,8 @@ +# Configuration the apns relay worker. + +[apns-relay-worker] + +# How often should worker wakeup all registered devices +FREQUENCY = 4h + +@inline-secret@ apns-relayworker ../secrets/apns-relay-worker.secret.conf diff --git a/debian/etc/taler-apns-relay/overrides.conf b/debian/etc/taler-apns-relay/overrides.conf @@ -0,0 +1 @@ +# This configuration will be changed by tooling. Do not touch it manually. diff --git a/debian/etc/taler-apns-relay/secrets/apns-relay-db.secret.conf b/debian/etc/taler-apns-relay/secrets/apns-relay-db.secret.conf @@ -0,0 +1,8 @@ +[apns-relaydb-postgres] + +# Typically, there should only be a single line here, of the form: + +CONFIG=postgres:///taler-apns-relay + +# The details of the URI depend on where the database lives and how +# access control was configured. diff --git a/debian/etc/taler-apns-relay/secrets/apns-relay-worker.secret.conf b/debian/etc/taler-apns-relay/secrets/apns-relay-worker.secret.conf @@ -0,0 +1,10 @@ +[apns-relay-worker] + +# key ID +KEY_ID = + +# team ID +TEAM_ID = + +# bundle ID +BUNDLE_ID = +\ No newline at end of file diff --git a/debian/etc/taler-apns-relay/taler-apns-relay.conf b/debian/etc/taler-apns-relay/taler-apns-relay.conf @@ -0,0 +1,34 @@ +# Main entry point for the Taler APNs relay configuration. +# +# Structure: +# - taler-apns-relay.conf is the main configuration entry point +# used by all Taler APNs relay components (the file you are currently +# looking at. +# - overrides.conf contains configuration overrides that are +# set by some tools that help with the configuration, +# and should not be edited by humans. Comments in this file +# are not preserved. +# - conf.d/ contains configuration files for +# Taler APNs relay components, which can be read by all +# users of the system and are included by the main +# configuration. +# - secrets/ contains configuration snippets +# with secrets for particular services. +# These files should have restrictive permissions +# so that only users of the relevant services +# can read it. All files in it should end with +# ".secret.conf". + +# Inline configurations from all Taler APNs relay components. +@inline-matching@ conf.d/*.conf + +# Overrides from tools that help with configuration. +@inline@ overrides.conf + + +[paths] + +# Paths for the system-wide installation of the Taler APNs relay. Do not remove +# or change these unless you are very sure of what you are doing. + +APNS_RELAY_HOME = /var/lib/apns-relay +\ No newline at end of file diff --git a/debian/taler-apns-relay.conf b/debian/taler-apns-relay.conf @@ -0,0 +1,8 @@ +# Create services users +u taler-apns-relay-worker - "Taler APNs relay worker" /var/lib/taler-apns-relay +u taler-apns-relay-httpd - "Taler APNs relay server" /var/lib/taler-apns-relay + +# Create DB access group +g taler-apns-relay-db - +m taler-apns-relay-worker taler-apns-relay-db +m taler-apns-relay-httpd taler-apns-relay-db +\ No newline at end of file diff --git a/debian/taler-apns-relay.install b/debian/taler-apns-relay.install @@ -0,0 +1,16 @@ +debian/etc/taler-apns-relay etc/ +debian/etc/nginx/sites-available/taler-apns-relay etc/nginx/sites-available/ +debian/etc/apache2/sites-available/taler-apns-relay.conf etc/apache2/sites-available/ + +target/release/taler-apns-relay /usr/bin +contrib/taler-apns-relay-dbconfig /usr/bin + +common/taler-common/db/versioning.sql /usr/share/taler-apns-relay/sql/ +taler-apns-relay/db/apns-relay*.sql /usr/share/taler-apns-relay/sql/ + +taler-apns-relay/apns-relay.conf /usr/share/taler-apns-relay/config.d/ + +doc/prebuilt/man/taler-apns-relay.1 /usr/share/man/man1/ +doc/prebuilt/man/taler-apns-relay.conf.5 /usr/share/man/man5/ + +debian/taler-apns-relay.conf /usr/lib/sysusers.d/ +\ No newline at end of file diff --git a/debian/taler-apns-relay.postinst b/debian/taler-apns-relay.postinst @@ -0,0 +1,13 @@ +#!/bin/sh + +set -e + +if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then + if [ -x "$(command -v systemd-sysusers)" ]; then + systemd-sysusers + fi +fi + +#DEBHELPER# + +exit 0 +\ No newline at end of file diff --git a/debian/taler-apns-relay.taler-apns-relay-httpd.service b/debian/taler-apns-relay.taler-apns-relay-httpd.service @@ -0,0 +1,40 @@ +[Unit] +Description=GNU Taler APNs relay REST API +Requires=taler-apns-relay-httpd.socket +After=network.target postgres.service +PartOf=taler-apns-relay.target + +[Service] +User=taler-apns-relay-httpd +Type=exec + +Restart=always +RestartMode=direct +RestartSec=1ms +RestartPreventExitStatus=9 + +StartLimitBurst=5 +StartLimitInterval=5s + +ExecStart=/usr/bin/taler-apns-relay serve -c /etc/taler-apns-relay/taler-apns-relay.conf + +StandardOutput=journal +StandardError=journal + +PrivateTmp=yes +ProtectSystem=full +ProtectHome=yes +ProtectClock=yes +ProtectHostname=yes +ProtectControlGroups=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectProc=invisible +PrivateDevices=yes +NoNewPrivileges=yes + +Slice=taler-apns-relay.slice + +[Install] +WantedBy=multi-user.target diff --git a/debian/taler-apns-relay.taler-apns-relay-httpd.socket b/debian/taler-apns-relay.taler-apns-relay-httpd.socket @@ -0,0 +1,14 @@ +[Unit] +Description=GNU Taler APNs relay socket +PartOf=taler-apns-relay-httpd.service + +[Socket] +ListenStream=/run/taler-apns-relay/httpd/apns-relay-http.sock +Accept=no +Service=taler-apns-relay-httpd.service +SocketUser=taler-apns-relay-httpd +SocketGroup=www-data +SocketMode=0660 + +[Install] +WantedBy=sockets.target +\ No newline at end of file diff --git a/debian/taler-apns-relay.taler-apns-relay-worker.service b/debian/taler-apns-relay.taler-apns-relay-worker.service @@ -0,0 +1,39 @@ +[Unit] +Description=GNU Taler APNs relay worker +After=network.target postgres.service +PartOf=taler-apns-relay.target + +[Service] +User=taler-apns-relay-worker +Type=exec + +Restart=always +RestartMode=direct +RestartSec=1ms +RestartPreventExitStatus=9 + +StartLimitBurst=5 +StartLimitInterval=5s + +ExecStart=/usr/bin/taler-apns-relay worker -c /etc/taler-apns-relay/taler-apns-relay.conf + +StandardOutput=journal +StandardError=journal + +PrivateTmp=yes +ProtectSystem=full +ProtectHome=yes +ProtectClock=yes +ProtectHostname=yes +ProtectControlGroups=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectProc=invisible +PrivateDevices=yes +NoNewPrivileges=yes + +Slice=taler-apns-relay.slice + +[Install] +WantedBy=multi-user.target diff --git a/debian/taler-apns-relay.taler-apns-relay.slice b/debian/taler-apns-relay.taler-apns-relay.slice @@ -0,0 +1,3 @@ +[Unit] +Description=Slice for GNU Taler APNs relay processes +Before=slices.target +\ No newline at end of file diff --git a/debian/taler-apns-relay.taler-apns-relay.target b/debian/taler-apns-relay.taler-apns-relay.target @@ -0,0 +1,9 @@ +[Unit] +Description=GNU Taler APNs relay +After=postgres.service network.target + +Wants=taler-apns-relay-httpd.service +Wants=taler-apns-relay-worker.service + +[Install] +WantedBy=multi-user.target +\ No newline at end of file diff --git a/debian/taler-apns-relay.tmpfiles b/debian/taler-apns-relay.tmpfiles @@ -0,0 +1,7 @@ +# Create home directory +d$ /var/lib/taler-apns-relay 0700 taler-apns-relay-worker taler-apns-relay-worker - - + +# Update secret files permissions +z /etc/taler-apns-relay/secrets/apns-relay-db.secret.conf 0460 root taler-apns-relay-db - - +z /etc/taler-apns-relay/secrets/apns-relay-httpd.secret.conf 0640 taler-apns-relay-httpd root - - +z /etc/taler-apns-relay/secrets/apns-relay-worker.secret.conf 0640 taler-apns-relay-worker root - - diff --git a/taler-apns-relay/Cargo.toml b/taler-apns-relay/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "taler-apns-relay" +version = "0.0.0" +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true + +[lib] +doctest = false + +[dependencies] +sqlx.workspace = true +serde.workspace = true +serde_json.workspace = true +axum.workspace = true +taler-common.workspace = true +taler-api.workspace = true +taler-build.workspace = true +anyhow.workspace = true +clap.workspace = true +hyper.workspace = true +http.workspace = true +hyper-util.workspace = true +hyper-rustls.workspace = true +http-body-util.workspace = true +rustls.workspace = true +jiff.workspace = true +tokio.workspace = true +aws-lc-rs.workspace = true +base64.workspace = true +compact_str.workspace = true +tracing.workspace = true +thiserror.workspace = true +strum_macros = "0.28" +rustls-pki-types = "1" + +[dev-dependencies] +taler-test-utils.workspace = true diff --git a/taler-apns-relay/apns-relay.conf b/taler-apns-relay/apns-relay.conf @@ -0,0 +1,38 @@ +[apns-relay-worker] +# Path to the Apple Push Notification private key +KEY_FILE = ${APNS_RELAY_HOME}/key.p8 + +# key ID +KEY_ID = + +# team ID +TEAM_ID = + +# bundle ID +BUNDLE_ID = + +# How often should worker wakeup all registered devices +FREQUENCY = 4h + +[apns-relay-httpd] +# How "taler-cyclos serve" serves its API, this can either be tcp or unix +SERVE = tcp + +# Port on which the HTTP server listens, e.g. 9967. Only used if SERVE is tcp. +PORT = 8080 + +# Which IP address should we bind to? E.g. ``127.0.0.1`` or ``::1``for loopback. Only used if SERVE is tcp. +BIND_TO = 0.0.0.0 + +# Which unix domain path should we bind to? Only used if SERVE is unix. +# UNIXPATH = taler-cyclos.sock + +# What should be the file access permissions for UNIXPATH? Only used if SERVE is unix. +# UNIXPATH_MODE = 660 + +[apns-relaydb-postgres] +# DB connection string +CONFIG = postgres:///taler-apns-relay + +# Where are the SQL files to setup our tables? +SQL_DIR = ${DATADIR}/sql/ +\ No newline at end of file diff --git a/taler-apns-relay/db/apns-relay-0001.sql b/taler-apns-relay/db/apns-relay-0001.sql @@ -0,0 +1,28 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +SELECT _v.register_patch('apns-relay-0001', NULL, NULL); + +CREATE SCHEMA apns_relay; +SET search_path TO apns_relay; + + +CREATE TABLE devices ( + device_id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + token TEXT NOT NULL UNIQUE, + registered_at INT8 NOT NULL +); + +COMMENT ON TABLE devices IS 'Registered device'; +\ No newline at end of file diff --git a/taler-apns-relay/db/apns-relay-drop.sql b/taler-apns-relay/db/apns-relay-drop.sql @@ -0,0 +1,29 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +DO +$do$ +DECLARE + patch text; +BEGIN + IF EXISTS(SELECT FROM information_schema.schemata WHERE schema_name='_v') THEN + FOR patch IN SELECT patch_name FROM _v.patches WHERE patch_name LIKE 'apns_relay_%' LOOP + PERFORM _v.unregister_patch(patch); + END LOOP; + END IF; +END +$do$; + +DROP SCHEMA IF EXISTS apns_relay CASCADE; +\ No newline at end of file diff --git a/taler-apns-relay/db/apns-relay-procedures.sql b/taler-apns-relay/db/apns-relay-procedures.sql @@ -0,0 +1,39 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +SET search_path TO apns_relay; + +-- Remove all existing functions +DO +$do$ +DECLARE + _sql text; +BEGIN + SELECT INTO _sql + string_agg(format('DROP %s %s CASCADE;' + , CASE prokind + WHEN 'f' THEN 'FUNCTION' + WHEN 'p' THEN 'PROCEDURE' + END + , oid::regprocedure) + , E'\n') + FROM pg_proc + WHERE pronamespace = 'apns_relay'::regnamespace; + + IF _sql IS NOT NULL THEN + EXECUTE _sql; + END IF; +END +$do$; +\ No newline at end of file diff --git a/taler-apns-relay/src/api.rs b/taler-apns-relay/src/api.rs @@ -0,0 +1,160 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse as _, NoContent}, + routing::{get, post}, +}; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use taler_api::{error::ApiResult, json::Req}; + +use crate::db; + +pub struct RelayApi { + db: PgPool, +} + +impl RelayApi { + pub fn new(db: PgPool) -> Self { + Self { db } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct DeviceRegistrationRequest { + pub token: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct DeviceUnregistrationRequest { + pub token: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ApnsRelayConfig<'a> { + pub name: &'a str, + pub version: &'a str, + pub implementation: Option<&'a str>, +} + +pub fn router(state: Arc<RelayApi>) -> Router { + Router::new() + .route( + "/devices", + post( + async |State(state): State<Arc<RelayApi>>, + Req(req): Req<DeviceRegistrationRequest>| { + db::register(&state.db, &req.token, &Timestamp::now()).await?; + ApiResult::Ok(NoContent) + }, + ) + .delete( + async |State(state): State<Arc<RelayApi>>, + Req(req): Req<DeviceRegistrationRequest>| { + db::unregister(&state.db, &req.token, &Timestamp::now()).await?; + ApiResult::Ok(NoContent) + }, + ), + ) + .route( + "/config", + get(async || { + Json(ApnsRelayConfig { + name: "taler-apns-relay", + version: "0:0:0", + implementation: Some("urn:net:taler:specs:taler-apns-relay:taler-rust"), + }) + .into_response() + }), + ) + .with_state(state) +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use taler_api::api::TalerRouter as _; + use taler_test_utils::{json, server::TestServer as _}; + + use crate::{ + api::{ApnsRelayConfig, RelayApi, router}, + db::{all_registrations, test::setup}, + }; + + #[tokio::test] + async fn api() { + let pool = setup().await; + let api = Arc::new(RelayApi::new(pool.clone())); + let server = router(api).finalize(); + + server + .get("/config") + .await + .assert_ok_json::<ApnsRelayConfig>(); + + assert_eq!(all_registrations(&pool).await.unwrap(), &[""; 0]); + + server + .post("/devices") + .json(&json!({ + "token": "device1" + })) + .await + .assert_no_content(); + server + .post("/devices") + .json(&json!({ + "token": "device1" + })) + .await + .assert_no_content(); + server + .post("/devices") + .json(&json!({ + "token": "device2" + })) + .await + .assert_no_content(); + + assert_eq!( + all_registrations(&pool).await.unwrap(), + &["device1", "device2"] + ); + + server + .delete("/devices") + .json(&json!({ + "token": "device1" + })) + .await + .assert_no_content(); + server + .delete("/devices") + .json(&json!({ + "token": "device3" + })) + .await + .assert_no_content(); + assert_eq!(all_registrations(&pool).await.unwrap(), &["device2"]); + } +} diff --git a/taler-apns-relay/src/apns.rs b/taler-apns-relay/src/apns.rs @@ -0,0 +1,367 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::time::Duration; + +use anyhow::{anyhow, bail}; +use aws_lc_rs::{ + rand::SystemRandom, + signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}, +}; +use base64::{Engine as _, prelude::BASE64_STANDARD}; +use compact_str::CompactString; +use http::{StatusCode, header::CONTENT_TYPE}; +use http_body_util::{BodyExt, Full}; +use hyper::{Method, body::Bytes, header::AUTHORIZATION}; +use hyper_rustls::ConfigBuilderExt as _; +use hyper_util::rt::{TokioExecutor, TokioTimer}; +use jiff::{SignedDuration, Timestamp}; +use rustls_pki_types::{PrivateKeyDer, pem::PemObject}; +use serde::Deserialize; +use taler_common::error::FmtSource; + +use crate::config::ApnsConfig; + +/// Raw JSON body returned by APNs when a push is rejected. +#[derive(Debug, Deserialize)] +pub struct ApnsErrorBody { + pub reason: CompactString, + /// Milliseconds since epoch. Only present when status is 410 (Unregistered/ExpiredToken). + pub timestamp: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, strum_macros::AsRefStr, strum_macros::Display)] +pub enum Reason { + BadCollapseId, + BadDeviceToken, + BadExpirationDate, + BadMessageId, + BadPriority, + BadTopic, + DeviceTokenNotForTopic, + DuplicateHeaders, + IdleTimeout, + InvalidPushType, + MissingDeviceToken, + MissingTopic, + PayloadEmpty, + TopicDisallowed, + BadCertificate, + BadCertificateEnvironment, + ExpiredProviderToken, + Forbidden, + InvalidProviderToken, + MissingProviderToken, + UnrelatedKeyIdInToken, + BadEnvironmentKeyIdInToken, + BadPath, + MethodNotAllowed, + ExpiredToken, + Unregistered, + PayloadTooLarge, + TooManyProviderTokenUpdates, + TooManyRequests, + InternalServerError, + ServiceUnavailable, + Shutdown, +} + +impl Reason { + /// Returns the documentation description associated with the error + pub fn description(&self) -> &'static str { + match self { + Self::BadCollapseId => "The collapse identifier exceeds the maximum allowed size", + Self::BadDeviceToken => "The specified device token is invalid", + Self::BadExpirationDate => "The apns-expiration value is invalid", + Self::BadMessageId => "The apns-id value is invalid", + Self::BadPriority => "The apns-priority value is invalid", + Self::BadTopic => "The apns-topic value is invalid", + Self::DeviceTokenNotForTopic => "The device token doesn't match the specified topic", + Self::DuplicateHeaders => "One or more headers are repeated", + Self::IdleTimeout => "Idle timeout", + Self::InvalidPushType => "The apns-push-type value is invalid", + Self::MissingDeviceToken => "The device token isn't specified in the request :path", + Self::MissingTopic => "The apns-topic header is missing and required", + Self::PayloadEmpty => "The message payload is empty", + Self::TopicDisallowed => "Pushing to this topic is not allowed", + Self::BadCertificate => "The certificate is invalid", + Self::BadCertificateEnvironment => { + "The client certificate doesn't match the environment" + } + Self::ExpiredProviderToken => "The provider token is stale", + Self::Forbidden => "The specified action is not allowed", + Self::InvalidProviderToken => "The provider token is not valid", + Self::MissingProviderToken => "No provider certificate or token was specified", + Self::UnrelatedKeyIdInToken => { + "The key ID in the provider token is unrelated to this connection" + } + Self::BadEnvironmentKeyIdInToken => { + "The key ID in the provider token doesn’t match the environment" + } + Self::BadPath => "The request contained an invalid :path value", + Self::MethodNotAllowed => "The specified :method value isn't POST", + Self::ExpiredToken => "The device token has expired", + Self::Unregistered => "The device token is inactive for the specified topic", + Self::PayloadTooLarge => "The message payload is too large", + Self::TooManyProviderTokenUpdates => { + "The authentication token is being updated too often" + } + Self::TooManyRequests => "Too many requests to the same device token", + Self::InternalServerError => "An internal server error occurred", + Self::ServiceUnavailable => "The service is unavailable", + Self::Shutdown => "The APNs server is shutting down", + } + } + + /// Returns the HTTP status code associated with the error + pub fn status_code(&self) -> u16 { + match self { + Self::BadCollapseId + | Self::BadDeviceToken + | Self::BadExpirationDate + | Self::BadMessageId + | Self::BadPriority + | Self::BadTopic + | Self::DeviceTokenNotForTopic + | Self::DuplicateHeaders + | Self::IdleTimeout + | Self::InvalidPushType + | Self::MissingDeviceToken + | Self::MissingTopic + | Self::PayloadEmpty + | Self::TopicDisallowed => 400, + + Self::BadCertificate + | Self::BadCertificateEnvironment + | Self::ExpiredProviderToken + | Self::Forbidden + | Self::InvalidProviderToken + | Self::MissingProviderToken + | Self::UnrelatedKeyIdInToken + | Self::BadEnvironmentKeyIdInToken => 403, + Self::BadPath => 404, + Self::MethodNotAllowed => 405, + Self::ExpiredToken | Self::Unregistered => 410, + Self::PayloadTooLarge => 413, + Self::TooManyProviderTokenUpdates | Self::TooManyRequests => 429, + Self::InternalServerError => 500, + Self::ServiceUnavailable | Self::Shutdown => 503, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ApnsError { + #[error("HTTP request: {0}")] + ReqTransport(FmtSource<hyper_util::client::legacy::Error>), + #[error("HTTP response: {0}")] + ResTransport(FmtSource<hyper::Error>), + #[error("response {0} JSON body: '{1}' - {2}")] + ResJson(StatusCode, Box<str>, serde_json::Error), + #[error("APNs unknown error {0}: {1}")] + ErrUnknown(StatusCode, CompactString), + #[error("APNs error {} {reason} - {}", reason.status_code(), reason.description())] + Err { + reason: Reason, + timestamp: Option<u64>, + }, +} + +pub struct Client { + http: hyper_util::client::legacy::Client< + hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>, + Full<Bytes>, + >, + key_pair: EcdsaKeyPair, + key_id: CompactString, + team_id: CompactString, + bundle_id: CompactString, + token: Box<str>, + issued_at: Timestamp, +} + +impl Client { + pub fn new(cfg: &ApnsConfig) -> anyhow::Result<Self> { + let ApnsConfig { + key_path, + key_id, + team_id, + bundle_id, + } = cfg; + + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("failed to install the default TLS provider"); + + // Load the signature key pair + let private_key_der = PrivateKeyDer::from_pem_file(key_path) + .map_err(|e| anyhow!("failed to read key file at '{key_path}': {e}"))?; + let PrivateKeyDer::Pkcs8(pkcs8_der) = private_key_der else { + bail!("invalid key file at '{key_path}': not a valid PKCS#8 private key"); + }; + let key_pair = EcdsaKeyPair::from_pkcs8( + &ECDSA_P256_SHA256_FIXED_SIGNING, + pkcs8_der.secret_pkcs8_der(), + ) + .map_err(|_| anyhow!("invalid key file at '{key_path}': not a valid PKCS#8 private key"))?; + + // Make a signature + let now = Timestamp::now(); + let token = Self::create_token(&key_pair, key_id, team_id, &now)?; + + // Prepare the TLS client config + let tls = rustls::ClientConfig::builder() + .with_native_roots()? + .with_no_client_auth(); + + // Prepare the HTTPS connector + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls) + .https_only() + .enable_http2() + .build(); + + // Send HTTP/2 PING every 1 hour as per: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns#Follow-best-practices-while-sending-push-notifications-with-APNs + // Reuse a connection as long as possible. In most cases, you can reuse a connection for many hours to days. If your connection is mostly idle, you may send a HTTP2 PING frame after an hour of inactivity. Reusing a connection often results in less bandwidth and CPU consumption. + let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new()) + .timer(TokioTimer::new()) + .pool_idle_timeout(None) + .http2_only(true) + .http2_keep_alive_interval(Some(Duration::from_secs(60 * 60))) + .http2_keep_alive_while_idle(true) + .build(https); + + Ok(Self { + http, + key_pair, + key_id: key_id.clone(), + team_id: team_id.clone(), + bundle_id: bundle_id.clone(), + token, + issued_at: now, + }) + } + + pub async fn send(&mut self, device_token: &str) -> Result<(), ApnsError> { + let now = Timestamp::now(); + // Token expire after an hour + if now.duration_since(self.issued_at) > SignedDuration::from_mins(55) { + self.token = + Self::create_token(&self.key_pair, &self.key_id, &self.team_id, &now).unwrap(); + self.issued_at = now; + } + + let path = format!( + "https://{}/3/device/{device_token}", + "api.sandbox.push.apple.com" + ); + + let req = hyper::Request::builder() + .method(Method::POST) + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .header("apns-push-type", "background") + .header("apns-priority", "5") + .header("apns-collapse-id", "wakeup") + .header("apns-topic", self.bundle_id.as_str()) + .header(AUTHORIZATION, self.token.as_ref()) + .body(Full::new(Bytes::from_static( + r#"{"aps":{"content-available":1}}"#.as_bytes(), + ))) + .unwrap(); + + let (parts, body) = self + .http + .request(req) + .await + .map_err(|e| ApnsError::ReqTransport(e.into()))? + .into_parts(); + let status = parts.status; + if status == StatusCode::OK { + return Ok(()); + } + + let body = body + .collect() + .await + .map(|it| it.to_bytes()) + .map_err(|e| ApnsError::ResTransport(e.into()))?; + let body: ApnsErrorBody = serde_json::from_slice(&body).map_err(|e| { + ApnsError::ResJson( + status, + String::from_utf8_lossy(&body).to_string().into_boxed_str(), + e, + ) + })?; + let reason = match (status.as_u16(), body.reason.as_str()) { + (400, "BadCollapseId") => Reason::BadCollapseId, + (400, "BadDeviceToken") => Reason::BadDeviceToken, + (400, "BadExpirationDate") => Reason::BadExpirationDate, + (400, "BadMessageId") => Reason::BadMessageId, + (400, "BadPriority") => Reason::BadPriority, + (400, "BadTopic") => Reason::BadTopic, + (400, "DeviceTokenNotForTopic") => Reason::DeviceTokenNotForTopic, + (400, "DuplicateHeaders") => Reason::DuplicateHeaders, + (400, "IdleTimeout") => Reason::IdleTimeout, + (400, "InvalidPushType") => Reason::InvalidPushType, + (400, "MissingDeviceToken") => Reason::MissingDeviceToken, + (400, "MissingTopic") => Reason::MissingTopic, + (400, "PayloadEmpty") => Reason::PayloadEmpty, + (400, "TopicDisallowed") => Reason::TopicDisallowed, + (403, "BadCertificate") => Reason::BadCertificate, + (403, "BadCertificateEnvironment") => Reason::BadCertificateEnvironment, + (403, "ExpiredProviderToken") => Reason::ExpiredProviderToken, + (403, "Forbidden") => Reason::Forbidden, + (403, "InvalidProviderToken") => Reason::InvalidProviderToken, + (403, "MissingProviderToken") => Reason::MissingProviderToken, + (403, "UnrelatedKeyIdInToken") => Reason::UnrelatedKeyIdInToken, + (403, "BadEnvironmentKeyIdInToken") => Reason::BadEnvironmentKeyIdInToken, + (404, "BadPath") => Reason::BadPath, + (405, "MethodNotAllowed") => Reason::MethodNotAllowed, + (410, "ExpiredToken") => Reason::ExpiredToken, + (410, "Unregistered") => Reason::Unregistered, + (413, "PayloadTooLarge") => Reason::PayloadTooLarge, + (429, "TooManyProviderTokenUpdates") => Reason::TooManyProviderTokenUpdates, + (429, "TooManyRequests") => Reason::TooManyRequests, + (500, "InternalServerError") => Reason::InternalServerError, + (503, "ServiceUnavailable") => Reason::ServiceUnavailable, + (503, "Shutdown") => Reason::Shutdown, + _ => return Err(ApnsError::ErrUnknown(status, body.reason)), + }; + Err(ApnsError::Err { + reason, + timestamp: body.timestamp, + }) + } + + fn create_token( + key_pair: &EcdsaKeyPair, + key_id: &str, + team_id: &str, + issued_at: &Timestamp, + ) -> Result<Box<str>, anyhow::Error> { + let headers = format!(r#"{{"alg":"ES256","kid":"{key_id}"}}"#); + let payload = format!(r#"{{"iss":"{team_id}","iat":{}}}"#, issued_at.as_second()); + let token = format!( + "{}.{}", + BASE64_STANDARD.encode(headers), + BASE64_STANDARD.encode(payload) + ); + let signature = key_pair.sign(&SystemRandom::new(), token.as_bytes())?; + + Ok(format!("Bearer {}.{}", token, BASE64_STANDARD.encode(signature)).into_boxed_str()) + } +} diff --git a/taler-apns-relay/src/config.rs b/taler-apns-relay/src/config.rs @@ -0,0 +1,75 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::time::Duration; + +use compact_str::CompactString; +use taler_api::{Serve, config::DbCfg}; +use taler_common::config::{Config, ValueErr}; + +pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> { + DbCfg::parse(cfg.section("apns-relaydb-postgres")) +} + +/// taler-apns-relay httpd config +pub struct ServeCfg { + pub serve: Serve, +} + +impl ServeCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let sect = cfg.section("apns-relay-httpd"); + + let serve = Serve::parse(sect)?; + + Ok(Self { serve }) + } +} + +pub struct ApnsConfig { + pub key_path: String, + pub key_id: CompactString, + pub team_id: CompactString, + pub bundle_id: CompactString, +} + +impl ApnsConfig { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let sect = cfg.section("apns-relay-worker"); + Ok(Self { + key_path: sect.path("key_file").require()?, + key_id: sect.cstr("key_id").require()?, + team_id: sect.cstr("team_id").require()?, + bundle_id: sect.cstr("bundle_id").require()?, + }) + } +} + +/// taler-apns-relay worker config +pub struct WorkerCfg { + pub apns: ApnsConfig, + pub frequency: Duration, +} + +impl WorkerCfg { + pub fn parse(cfg: &Config) -> Result<Self, ValueErr> { + let sect = cfg.section("apns-relay-worker"); + Ok(Self { + apns: ApnsConfig::parse(cfg)?, + frequency: sect.duration("frequency").require()?, + }) + } +} diff --git a/taler-apns-relay/src/constants.rs b/taler-apns-relay/src/constants.rs @@ -0,0 +1,20 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use taler_common::config::parser::ConfigSource; + +pub const CONFIG_SOURCE: ConfigSource = + ConfigSource::new("taler-apns-relay", "apns-relay", "taler-apns-relay"); diff --git a/taler-apns-relay/src/db.rs b/taler-apns-relay/src/db.rs @@ -0,0 +1,139 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use jiff::Timestamp; +use sqlx::PgPool; +use taler_api::db::BindHelper; +use taler_common::config::Config; + +use crate::config::parse_db_cfg; + +const SCHEMA: &str = "apns_relay"; + +pub async fn pool(cfg: &Config) -> anyhow::Result<PgPool> { + let db = parse_db_cfg(cfg)?; + let pool = taler_common::db::pool(db.cfg, SCHEMA).await?; + Ok(pool) +} + +pub async fn dbinit(cfg: &Config, reset: bool) -> anyhow::Result<PgPool> { + let db_cfg = parse_db_cfg(cfg)?; + let pool = taler_common::db::pool(db_cfg.cfg, SCHEMA).await?; + let mut db = pool.acquire().await?; + taler_common::db::dbinit(&mut db, db_cfg.sql_dir.as_ref(), "apns-relay", reset).await?; + Ok(pool) +} + +/// Register a new device +pub async fn register(db: &PgPool, token: &str, now: &Timestamp) -> sqlx::Result<()> { + sqlx::query( + " + INSERT INTO devices (token, registered_at) + VALUES ($1, $2) + ON CONFLICT (token) DO NOTHING + ", + ) + .bind(token) + .bind_timestamp(now) + .execute(db) + .await?; + Ok(()) +} + +/// Unregister +pub async fn unregister(db: &PgPool, token: &str, before: &Timestamp) -> sqlx::Result<()> { + sqlx::query( + " + DELETE FROM devices + WHERE token = $1 AND registered_at <= $2 + ", + ) + .bind(token) + .bind_timestamp(before) + .execute(db) + .await?; + Ok(()) +} + +/// List all registered devices +pub async fn all_registrations(db: &PgPool) -> sqlx::Result<Vec<String>> { + sqlx::query_scalar("SELECT token FROM devices") + .fetch_all(db) + .await +} + +/// Remove all registered devices +pub async fn clear_registration(db: &PgPool) -> sqlx::Result<()> { + sqlx::query("TRUNCATE TABLE devices").execute(db).await?; + Ok(()) +} + +#[cfg(test)] +pub mod test { + use jiff::{SignedDuration, Timestamp}; + use sqlx::PgPool; + use taler_test_utils::db::db_test_setup; + + use crate::{ + constants::CONFIG_SOURCE, + db::{all_registrations, clear_registration, register, unregister}, + }; + + pub async fn setup() -> PgPool { + db_test_setup(CONFIG_SOURCE).await.1 + } + + #[tokio::test] + async fn registration() { + let db = setup().await; + let token1 = "device_token1"; + let token2 = "device_token2"; + let now = Timestamp::now(); + + // Empty + assert_eq!(all_registrations(&db).await.unwrap(), &[""; 0]); + clear_registration(&db).await.unwrap(); + + // Register + register(&db, token1, &now).await.unwrap(); + assert_eq!(all_registrations(&db).await.unwrap(), &[token1]); + + // Idempotent + register(&db, token1, &now).await.unwrap(); + assert_eq!(all_registrations(&db).await.unwrap(), &[token1]); + + // Many + register(&db, token2, &(now + SignedDuration::from_mins(1))) + .await + .unwrap(); + assert_eq!(all_registrations(&db).await.unwrap(), &[token1, token2]); + + // Unregister + unregister(&db, token1, &now).await.unwrap(); + assert_eq!(all_registrations(&db).await.unwrap(), &[token2]); + + // Idempotent + unregister(&db, &token1, &now).await.unwrap(); + assert_eq!(all_registrations(&db).await.unwrap(), &[token2]); + + // Skip reregistered + unregister(&db, token2, &now).await.unwrap(); + assert_eq!(all_registrations(&db).await.unwrap(), &[token2]); + + clear_registration(&db).await.unwrap(); + assert_eq!(all_registrations(&db).await.unwrap(), &[""; 0]); + } +} diff --git a/taler-apns-relay/src/lib.rs b/taler-apns-relay/src/lib.rs @@ -0,0 +1,61 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use anyhow::bail; +use sqlx::PgPool; +use taler_common::config::Config; +use tracing::info; + +use crate::{ + apns::{ApnsError, Client, Reason}, + config::ApnsConfig, +}; + +pub mod api; +pub mod apns; +pub mod config; +pub mod constants; +pub mod db; +pub mod worker; + +pub async fn setup(cfg: &Config, pool: &PgPool, reset: bool) -> anyhow::Result<()> { + let apns_cfg = ApnsConfig::parse(cfg)?; + + info!(target: "setup", "Check API access and configuration"); + let mut client = Client::new(&apns_cfg)?; + let res = client + .send("test_device_token") + .await + .expect_err("Should fail"); + + if !matches!( + res, + ApnsError::Err { + reason: Reason::BadDeviceToken, + timestamp: None + } + ) { + bail!("{res}") + } + + if reset { + info!(target: "setup", "Clear registration"); + db::clear_registration(pool).await?; + } + + info!(target: "setup", "Setup finished"); + Ok(()) +} diff --git a/taler-apns-relay/src/main.rs b/taler-apns-relay/src/main.rs @@ -0,0 +1,96 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::sync::Arc; + +use clap::Parser as _; +use taler_api::api::TalerRouter; +use taler_apns_relay::{ + api::{RelayApi, router}, + config::ServeCfg, + constants::CONFIG_SOURCE, + db::{dbinit, pool}, + setup, worker, +}; +use taler_build::long_version; +use taler_common::{CommonArgs, cli::ConfigCmd, config::Config, taler_main}; + +#[derive(clap::Parser, Debug)] +#[command(long_version = long_version(), about, long_about = None)] +struct Args { + #[clap(flatten)] + common: CommonArgs, + + #[command(subcommand)] + cmd: Command, +} + +#[derive(clap::Subcommand, Debug)] +enum Command { + /// Initialize taler-apns-relay database + Dbinit { + /// Reset database (DANGEROUS: All existing data is lost) + #[clap(long, short)] + reset: bool, + }, + /// Check taler-apns-relay config + Setup { + /// Remove all registered devices + #[clap(long, short)] + reset: bool, + }, + /// Run taler-apns-relay worker + Worker { + /// Execute once and return + #[clap(long, short)] + transient: bool, + }, + /// Run taler-apns-relay HTTP server + Serve, + #[command(subcommand)] + Config(ConfigCmd), +} + +async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> { + match cmd { + Command::Dbinit { reset } => { + dbinit(cfg, reset).await?; + } + Command::Setup { reset } => { + let pool = pool(cfg).await?; + setup(cfg, &pool, reset).await?; + } + Command::Serve => { + let pool = pool(cfg).await?; + let cfg = ServeCfg::parse(cfg)?; + let api = Arc::new(RelayApi::new(pool)); + router(api).serve(cfg.serve, None).await?; + } + Command::Worker { transient } => { + let pool = pool(cfg).await?; + worker::run(cfg, &pool, transient).await?; + } + Command::Config(cmd) => cmd.run(cfg)?, + } + Ok(()) +} + +fn main() { + let args = Args::parse(); + taler_main(CONFIG_SOURCE, args.common, async |cfg| { + run(args.cmd, &cfg).await + }); +} diff --git a/taler-apns-relay/src/worker.rs b/taler-apns-relay/src/worker.rs @@ -0,0 +1,130 @@ +/* + This file is part of TALER + Copyright (C) 2026 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::time::Duration; + +use jiff::Timestamp; +use sqlx::PgPool; +use taler_common::ExpoBackoffDecorr; +use taler_common::config::Config; +use tracing::{error, info}; + +use crate::{ + apns::{ApnsError, Client, Reason}, + config::WorkerCfg, + db, +}; + +#[derive(Debug, thiserror::Error)] +pub enum WorkerError { + #[error(transparent)] + Db(#[from] sqlx::Error), + #[error(transparent)] + Apns(#[from] ApnsError), +} + +async fn wakeup(pool: &PgPool, client: &mut Client) -> Result<(), WorkerError> { + let tokens = db::all_registrations(pool).await?; + info!(target: "worker", "send notification to {} devices", tokens.len()); + + for token in tokens { + let res = client.send(&token).await; + if let Err(ApnsError::Err { reason, timestamp }) = &res { + match reason { + // Fatal error + Reason::BadCollapseId + | Reason::BadDeviceToken + | Reason::BadExpirationDate + | Reason::BadMessageId + | Reason::BadPriority + | Reason::BadTopic + | Reason::DuplicateHeaders + | Reason::InvalidPushType + | Reason::MissingDeviceToken + | Reason::MissingTopic + | Reason::PayloadEmpty + | Reason::BadPath + | Reason::MethodNotAllowed => { + tracing::error!(target: "worker", "fatal error the service is broken: {}",res.unwrap_err()); + std::process::exit(9); + } + // Config error + Reason::TopicDisallowed + | Reason::BadCertificate + | Reason::BadCertificateEnvironment + | Reason::ExpiredProviderToken + | Reason::Forbidden + | Reason::InvalidProviderToken + | Reason::MissingProviderToken + | Reason::UnrelatedKeyIdInToken + | Reason::BadEnvironmentKeyIdInToken + | Reason::PayloadTooLarge => { + tracing::error!(target: "worker", "config error, check the configuration: {}",res.unwrap_err()); + std::process::exit(9); + } + // Unregister + Reason::DeviceTokenNotForTopic | Reason::ExpiredToken | Reason::Unregistered => { + db::unregister( + pool, + &token, + ×tamp + .and_then(|s| Timestamp::from_second(s as i64).ok()) + .unwrap_or_else(Timestamp::now), + ) + .await?; + } + // Wait before retry + Reason::IdleTimeout + | Reason::TooManyProviderTokenUpdates + | Reason::TooManyRequests => { + tokio::time::sleep(Duration::from_mins(15)).await; + } + // Restart loop + Reason::InternalServerError | Reason::ServiceUnavailable | Reason::Shutdown => { + tokio::time::sleep(Duration::from_mins(15)).await; + res?; + } + } + } else { + res?; + } + } + + Ok(()) +} + +pub async fn run(cfg: &Config, pool: &PgPool, transient: bool) -> anyhow::Result<()> { + let cfg = WorkerCfg::parse(cfg)?; + let mut client = Client::new(&cfg.apns)?; + let mut jitter = ExpoBackoffDecorr::default(); + + if transient { + wakeup(pool, &mut client).await?; + return Ok(()); + } + + info!(target: "worker", "running at initialisation"); + loop { + while let Err(e) = wakeup(pool, &mut client).await { + error!(target: "worker", "{e}"); + tokio::time::sleep(jitter.backoff()).await; + } + + // TODO take sending time into account + tokio::time::sleep(cfg.frequency).await; + info!(target: "worker", "running at frequency"); + } +} diff --git a/taler-cyclos/Cargo.toml b/taler-cyclos/Cargo.toml @@ -34,5 +34,4 @@ hyper.workspace = true url.workspace = true [dev-dependencies] -taler-test-utils.workspace = true -rand.workspace = true +taler-test-utils.workspace = true +\ No newline at end of file diff --git a/taler-magnet-bank/Cargo.toml b/taler-magnet-bank/Cargo.toml @@ -41,5 +41,4 @@ aws-lc-rs.workspace = true compact_str.workspace = true [dev-dependencies] -taler-test-utils.workspace = true -rand.workspace = true +taler-test-utils.workspace = true +\ No newline at end of file