taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit 3ce4de773233fad0b30fa5f8cba4efd0bf8dff8f
parent 63a69222fd57e29ab967e8d52793f741ed47bc49
Author: Antoine A <>
Date:   Tue, 27 Oct 2026 14:41:09 +0100

apns-relay: fix many remaining issues issues

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mcommon/taler-common/Cargo.toml | 2+-
Mdebian/etc/taler-apns-relay/conf.d/apns-relay-worker.conf | 3+++
Mtaler-apns-relay/Cargo.toml | 1+
Mtaler-apns-relay/apns-relay.conf | 6++++--
Mtaler-apns-relay/src/api.rs | 34+++++++++++++++++++++-------------
Mtaler-apns-relay/src/apns.rs | 60++++++++++++++++++++++++++++++++++++++++++++----------------
Mtaler-apns-relay/src/config.rs | 26+++++++++++++++++++++++++-
Mtaler-apns-relay/src/db.rs | 35+++++++++++++++++++----------------
Mtaler-apns-relay/src/lib.rs | 5+----
Mtaler-apns-relay/src/worker.rs | 24++++++++++++++++--------
12 files changed, 137 insertions(+), 61 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2661,6 +2661,7 @@ dependencies = [ "anyhow", "aws-lc-rs", "axum", + "base64 0.23.1", "clap", "compact_str", "http", diff --git a/Cargo.toml b/Cargo.toml @@ -20,6 +20,7 @@ lto = "fat" codegen-units = 1 [workspace.dependencies] +base64 = "0.23" thiserror = "2.0" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } diff --git a/common/taler-common/Cargo.toml b/common/taler-common/Cargo.toml @@ -14,7 +14,7 @@ doctest = false [dependencies] glob = "0.3" indexmap = "2.7" -base64 = "0.23" +base64.workspace = true tempfile.workspace = true jiff.workspace = true serde.workspace = true 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 @@ -2,6 +2,9 @@ [apns-relay-worker] +# APNs environment: production or sandbox +# SERVER = production + # How often should worker wakeup all registered devices FREQUENCY = 4h diff --git a/taler-apns-relay/Cargo.toml b/taler-apns-relay/Cargo.toml @@ -12,6 +12,7 @@ repository.workspace = true doctest = false [dependencies] +base64.workspace = true sqlx.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/taler-apns-relay/apns-relay.conf b/taler-apns-relay/apns-relay.conf @@ -1,4 +1,7 @@ [apns-relay-worker] +# APNs environment: production or sandbox +SERVER = production + # Path to the Apple Push Notification private key KEY_FILE = ${APNS_RELAY_HOME}/key.p8 @@ -35,4 +38,4 @@ BIND_TO = 0.0.0.0 CONFIG = postgres:///taler-apns-relay # Where are the SQL files to setup our tables? -SQL_DIR = ${DATADIR}/sql/ -\ No newline at end of file +SQL_DIR = ${DATADIR}/sql/ diff --git a/taler-apns-relay/src/api.rs b/taler-apns-relay/src/api.rs @@ -26,10 +26,12 @@ use jiff::Timestamp; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use taler_api::{ - error::ApiResult, + error::{ApiResult, failure}, extract::{Path, Req}, }; +use taler_common::error_code::ErrorCode; + use crate::db; pub struct RelayApi { @@ -65,8 +67,17 @@ pub fn router(state: Arc<RelayApi>) -> Router { "/devices", post( async |State(state): State<Arc<RelayApi>>, - Req(req): Req<DeviceRegistrationRequest>| { - db::register(&state.db, &req.token, &Timestamp::now()).await?; + Req(DeviceRegistrationRequest { token}): Req<DeviceRegistrationRequest>| { + if token.is_empty() + || !token.len().is_multiple_of(2) + || !token.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(failure( + ErrorCode::GENERIC_PARAMETER_MALFORMED, + "token must be a nonempty, even-length ASCII hexadecimal string", + ) + .with_path("token")); + } + db::register(&state.db, &token, &Timestamp::now()).await?; ApiResult::Ok(NoContent) }, ), @@ -123,32 +134,29 @@ mod test { server .post("/devices") .json(json!({ - "token": "device1" + "token": "aa01" })) .await .assert_no_content(); server .post("/devices") .json(json!({ - "token": "device1" + "token": "aa01" })) .await .assert_no_content(); server .post("/devices") .json(json!({ - "token": "device2" + "token": "aa02" })) .await .assert_no_content(); - assert_eq!( - all_registrations(&pool).await.unwrap(), - &["device1", "device2"] - ); + assert_eq!(all_registrations(&pool).await.unwrap(), &["aa01", "aa02"]); - server.delete("/devices/device1").await.assert_no_content(); - server.delete("/devices/device3").await.assert_no_content(); - assert_eq!(all_registrations(&pool).await.unwrap(), &["device2"]); + server.delete("/devices/aa01").await.assert_no_content(); + server.delete("/devices/aa03").await.assert_no_content(); + assert_eq!(all_registrations(&pool).await.unwrap(), &["aa02"]); } } diff --git a/taler-apns-relay/src/apns.rs b/taler-apns-relay/src/apns.rs @@ -20,6 +20,7 @@ use aws_lc_rs::{ rand::SystemRandom, signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair}, }; +use base64::{display::Base64Display, engine::general_purpose::URL_SAFE_NO_PAD}; use compact_str::CompactString; use http::{StatusCode, header::CONTENT_TYPE}; use http_body_util::{BodyExt, Full}; @@ -29,10 +30,10 @@ use hyper_util::rt::{TokioExecutor, TokioTimer}; use jiff::{SignedDuration, Timestamp}; use rustls_pki_types::{PrivateKeyDer, pem::PemObject}; use serde::Deserialize; -use taler_common::{encoding::base64, error::FmtSource}; +use taler_common::error::FmtSource; use taler_macros::EnumMeta; -use crate::config::ApnsConfig; +use crate::config::{ApnsConfig, ApnsServer}; /// Raw JSON body returned by APNs when a push is rejected. #[derive(Debug, Deserialize)] @@ -172,6 +173,7 @@ pub struct Client { Full<Bytes>, >, key_pair: EcdsaKeyPair, + server: ApnsServer, key_id: CompactString, team_id: CompactString, bundle_id: CompactString, @@ -182,6 +184,7 @@ pub struct Client { impl Client { pub fn new(cfg: &ApnsConfig) -> anyhow::Result<Self> { let ApnsConfig { + server, key_path, key_id, team_id, @@ -219,7 +222,7 @@ impl Client { // Make a signature let now = Timestamp::now(); - let token = Self::create_token(&key_pair, key_id, team_id, &now)?; + let token = Self::create_token(&key_pair, key_id, team_id, &now); // Prepare the TLS client config let tls = rustls::ClientConfig::builder() @@ -247,6 +250,7 @@ impl Client { Ok(Self { http, key_pair, + server: *server, key_id: key_id.clone(), team_id: team_id.clone(), bundle_id: bundle_id.clone(), @@ -259,19 +263,36 @@ impl Client { 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; + self.refresh_token(); } - let path = format!( - "https://{}/3/device/{device_token}", - "api.sandbox.push.apple.com" - ); + let result = self.send_once(device_token).await; + if matches!( + &result, + Err(ApnsError::Err { + reason: Reason::ExpiredProviderToken, + .. + }) + ) { + self.refresh_token(); + return self.send_once(device_token).await; + } + result + } + + fn refresh_token(&mut self) { + let now = Timestamp::now(); + self.token = Self::create_token(&self.key_pair, &self.key_id, &self.team_id, &now); + self.issued_at = now; + } + async fn send_once(&self, device_token: &str) -> Result<(), ApnsError> { let req = hyper::Request::builder() .method(Method::POST) - .uri(&path) + .uri(format!( + "https://{}/3/device/{device_token}", + self.server.host() + )) .header(CONTENT_TYPE, "application/json") .header("apns-push-type", "background") .header("apns-priority", "5") @@ -352,16 +373,23 @@ impl Client { key_id: &str, team_id: &str, issued_at: &Timestamp, - ) -> Result<Box<str>, anyhow::Error> { + ) -> Box<str> { 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::fmt(headers.as_bytes()), - base64::fmt(payload.as_bytes()) + Base64Display::new(headers.as_bytes(), &URL_SAFE_NO_PAD), + Base64Display::new(payload.as_bytes(), &URL_SAFE_NO_PAD) ); - let signature = key_pair.sign(&SystemRandom::new(), token.as_bytes())?; + let signature = key_pair + .sign(&SystemRandom::new(), token.as_bytes()) + .unwrap(); - Ok(format!("Bearer {}.{}", token, base64::fmt(signature.as_ref())).into_boxed_str()) + format!( + "Bearer {}.{}", + token, + Base64Display::new(signature.as_ref(), &URL_SAFE_NO_PAD) + ) + .into_boxed_str() } } diff --git a/taler-apns-relay/src/config.rs b/taler-apns-relay/src/config.rs @@ -18,7 +18,10 @@ use std::time::Duration; use compact_str::CompactString; use taler_api::{Serve, config::DbCfg}; -use taler_common::config::{CfgErr, Config}; +use taler_common::{ + config::{CfgErr, Config}, + map_config, +}; pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, CfgErr> { DbCfg::parse(cfg.section("apns-relaydb-postgres")) @@ -39,7 +42,23 @@ impl ServeCfg { } } +#[derive(Debug, Clone, Copy)] +pub enum ApnsServer { + Production, + Sandbox, +} + +impl ApnsServer { + pub fn host(self) -> &'static str { + match self { + Self::Production => "api.push.apple.com", + Self::Sandbox => "api.sandbox.push.apple.com", + } + } +} + pub struct ApnsConfig { + pub server: ApnsServer, pub key_path: String, pub key_id: CompactString, pub team_id: CompactString, @@ -50,6 +69,11 @@ impl ApnsConfig { pub fn parse(cfg: &Config) -> Result<Self, CfgErr> { let sect = cfg.section("apns-relay-worker"); Ok(Self { + server: map_config!(&sect, "APNs server", "server", + "production" => { ApnsServer::Production }, + "sandbox" => { ApnsServer::Sandbox }, + ) + .default(ApnsServer::Production)?, key_path: sect.path("key_file").require()?, key_id: sect.cstr("key_id").require()?, team_id: sect.cstr("team_id").require()?, diff --git a/taler-apns-relay/src/db.rs b/taler-apns-relay/src/db.rs @@ -16,7 +16,7 @@ use jiff::Timestamp; use sqlx::PgPool; -use taler_api::db::BindHelper; +use taler_api::{db::BindHelper, serialized}; use taler_common::config::Config; use crate::config::parse_db_cfg; @@ -39,32 +39,35 @@ pub async fn dbinit(cfg: &Config, reset: bool) -> anyhow::Result<PgPool> { /// Register a new device pub async fn register(db: &PgPool, token: &str, now: &Timestamp) -> sqlx::Result<()> { - sqlx::query( - " + serialized!( + sqlx::query( + " INSERT INTO devices (token, registered_at) VALUES ($1, $2) - ON CONFLICT (token) DO NOTHING + ON CONFLICT (token) DO UPDATE + SET registered_at = GREATEST(devices.registered_at, EXCLUDED.registered_at) ", - ) - .bind(token) - .bind_timestamp(now) - .execute(db) - .await?; + ) + .bind(token) + .bind_timestamp(now) + .execute(db) + )?; Ok(()) } /// Unregister pub async fn unregister(db: &PgPool, token: &str, before: &Timestamp) -> sqlx::Result<()> { - sqlx::query( - " + serialized!( + sqlx::query( + " DELETE FROM devices WHERE token = $1 AND registered_at <= $2 ", - ) - .bind(token) - .bind_timestamp(before) - .execute(db) - .await?; + ) + .bind(token) + .bind_timestamp(before) + .execute(db) + )?; Ok(()) } diff --git a/taler-apns-relay/src/lib.rs b/taler-apns-relay/src/lib.rs @@ -37,10 +37,7 @@ pub async fn setup(cfg: &Config, pool: &PgPool, reset: bool) -> anyhow::Result<( 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"); + let res = client.send("00").await.expect_err("Should fail"); if !matches!( res, diff --git a/taler-apns-relay/src/worker.rs b/taler-apns-relay/src/worker.rs @@ -52,7 +52,7 @@ async fn wakeup(pool: &PgPool, client: &mut Client) -> Result<(), WorkerError> { // TODO paginate tokens // TODO send in batches - for token in tokens { + 'devices: for token in tokens { trace!(target: "worker", "send background notification to {token}"); let mut attempt = 0; while let Err(e) = client.send(&token).await { @@ -61,7 +61,6 @@ async fn wakeup(pool: &PgPool, client: &mut Client) -> Result<(), WorkerError> { match reason { // Fatal error Reason::BadCollapseId - | Reason::BadDeviceToken | Reason::BadExpirationDate | Reason::BadMessageId | Reason::BadPriority @@ -80,7 +79,6 @@ async fn wakeup(pool: &PgPool, client: &mut Client) -> Result<(), WorkerError> { Reason::TopicDisallowed | Reason::BadCertificate | Reason::BadCertificateEnvironment - | Reason::ExpiredProviderToken | Reason::Forbidden | Reason::InvalidProviderToken | Reason::MissingProviderToken @@ -90,24 +88,32 @@ async fn wakeup(pool: &PgPool, client: &mut Client) -> Result<(), WorkerError> { error!(target: "worker", "config error, check the configuration: {e}"); std::process::exit(6); } + // The client already retried once with a fresh provider token. + Reason::ExpiredProviderToken => return Err(e.into()), // Unregister - Reason::DeviceTokenNotForTopic + Reason::BadDeviceToken + | Reason::DeviceTokenNotForTopic | Reason::ExpiredToken | Reason::Unregistered => { db::unregister( pool, &token, &timestamp - .and_then(|s| Timestamp::from_second(s as i64).ok()) + .and_then(|s| i64::try_from(s).ok()) + .and_then(|s| Timestamp::from_millisecond(s).ok()) .unwrap_or_else(Timestamp::now), ) .await?; - break; + continue 'devices; + } + // Retry this device on the next scheduled pass. + Reason::TooManyRequests => { + warn!(target: "worker", "notification to {token} rate-limited, deferring to the next pass"); + continue 'devices; } // Wait before retry Reason::IdleTimeout | Reason::TooManyProviderTokenUpdates - | Reason::TooManyRequests | Reason::InternalServerError | Reason::ServiceUnavailable | Reason::Shutdown => {} @@ -115,8 +121,10 @@ async fn wakeup(pool: &PgPool, client: &mut Client) -> Result<(), WorkerError> { } attempt += 1; + if attempt >= NB_RETRY { - error!(target: "worker", "notification to {token} failed more than {NB_RETRY} times, stopping worker"); + error!(target: "worker", "notification to {token} failed more than {NB_RETRY} times, stopping worker after back-off"); + tokio::time::sleep(jitter.backoff()).await; return Err(WorkerError::Apns(e)); } else { warn!(target: "worker", "notification to {token} failed {e}, retrying after back-off");