From f5b685a7dab901378b7c2bed5b50e61d396128cd Mon Sep 17 00:00:00 2001 From: raphaelrobert Date: Mon, 9 Dec 2024 12:30:55 +0100 Subject: [PATCH] feat: Server support for Android push notifications (#236) * feat: Server support for Android push notifications * Address review comments --- Cargo.lock | 60 +-- backend/src/qs/client_record.rs | 8 + backend/src/qs/mod.rs | 4 + backend/src/settings.rs | 9 + server/Cargo.toml | 2 + .../qs/push_notification_provider.rs | 392 ++++++++++++++---- server/src/main.rs | 5 +- test_harness/src/utils/mod.rs | 2 +- 8 files changed, 358 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3cd27d0f..d811f494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,9 +447,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" dependencies = [ "backtrace", ] @@ -2008,7 +2008,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.1.0", + "http 1.2.0", "indexmap", "slab", "tokio", @@ -2182,9 +2182,9 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" dependencies = [ "bytes", "fnv", @@ -2198,7 +2198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http 1.2.0", ] [[package]] @@ -2209,7 +2209,7 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body", "pin-project-lite", ] @@ -2242,7 +2242,7 @@ dependencies = [ "futures-channel", "futures-util", "h2 0.4.7", - "http 1.1.0", + "http 1.2.0", "http-body", "httparse", "itoa", @@ -2259,7 +2259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", - "http 1.1.0", + "http 1.2.0", "hyper", "hyper-util", "rustls", @@ -2279,7 +2279,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.1.0", + "http 1.2.0", "http-body", "hyper", "pin-project-lite", @@ -3383,7 +3383,7 @@ dependencies = [ "base64 0.22.1", "env_logger 0.11.5", "futures-util", - "http 1.1.0", + "http 1.2.0", "log", "mls-assist", "phnxtypes", @@ -3492,6 +3492,7 @@ dependencies = [ "png", "reqwest", "serde", + "serde_json", "thiserror 1.0.69", "tls_codec", "tokio", @@ -3501,6 +3502,7 @@ dependencies = [ "tracing-futures", "tracing-log 0.2.0", "tracing-subscriber", + "zeroize", ] [[package]] @@ -3733,7 +3735,7 @@ dependencies = [ "base64 0.22.1", "blind-rsa-signatures", "generic-array", - "http 1.1.0", + "http 1.2.0", "nom", "p384", "rand", @@ -3820,7 +3822,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.3", + "thiserror 2.0.4", "tokio", "tracing", ] @@ -3839,7 +3841,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.3", + "thiserror 2.0.4", "tinyvec", "tracing", "web-time", @@ -4092,7 +4094,7 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.7", - "http 1.1.0", + "http 1.2.0", "http-body", "http-body-util", "hyper", @@ -4937,11 +4939,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa" +checksum = "2f49a1853cf82743e3b7950f77e0f4d622ca36cf4317cba00c767838bac8d490" dependencies = [ - "thiserror-impl 2.0.3", + "thiserror-impl 2.0.4", ] [[package]] @@ -4957,9 +4959,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.3" +version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568" +checksum = "8381894bb3efe0c4acac3ded651301ceee58a15d47c2e34885ed1908ad667061" dependencies = [ "proc-macro2", "quote", @@ -4998,9 +5000,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "itoa", @@ -5021,9 +5023,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" dependencies = [ "num-conv", "time-core", @@ -5087,9 +5089,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.41.1" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -5154,9 +5156,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" dependencies = [ "bytes", "futures-core", @@ -5334,7 +5336,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.1.0", + "http 1.2.0", "httparse", "log", "rand", diff --git a/backend/src/qs/client_record.rs b/backend/src/qs/client_record.rs index d35391f2..1a53c2b6 100644 --- a/backend/src/qs/client_record.rs +++ b/backend/src/qs/client_record.rs @@ -295,6 +295,14 @@ impl QsClientRecord { "Push notification failed because the JWT token could not be created: {}", e ), + PushNotificationError::OAuthError(e) => tracing::error!( + "Push notification failed because of an OAuth error: {}", + e + ), + PushNotificationError::InvalidConfiguration(e) => tracing::error!( + "Push notification failed because of an invalid configuration: {}", + e + ), } } } diff --git a/backend/src/qs/mod.rs b/backend/src/qs/mod.rs index b9edfd5c..db0df43a 100644 --- a/backend/src/qs/mod.rs +++ b/backend/src/qs/mod.rs @@ -165,6 +165,10 @@ pub enum PushNotificationError { UnsupportedType, /// The JWT token for APNS could not be created. JwtCreationError(String), + /// OAuth error. + OAuthError(String), + /// Configuration error. + InvalidConfiguration(String), } #[async_trait] diff --git a/backend/src/settings.rs b/backend/src/settings.rs index 6f13aaba..dac8fcda 100644 --- a/backend/src/settings.rs +++ b/backend/src/settings.rs @@ -12,6 +12,9 @@ pub struct Settings { // If this isn't present, the provider will not send push notifications to // apple devices. pub apns: Option, + // If this isn't present, the provider will not send push notifications to + // android devices. + pub fcm: Option, } /// Configuration for the application. @@ -33,6 +36,12 @@ pub struct DatabaseSettings { pub cacertpath: Option, } +#[derive(Debug, Deserialize, Clone)] +pub struct FcmSettings { + // The path to the service account key file. + pub path: String, +} + #[derive(Debug, Deserialize, Clone)] pub struct ApnsSettings { pub keyid: String, diff --git a/server/Cargo.toml b/server/Cargo.toml index d5cf6b62..4afc3fd4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,6 +25,7 @@ phnxbackend = { path = "../backend" } phnxtypes = { path = "../types" } actix-web = "^4.0" serde = "1" +serde_json = "1.0" config = "0.14" async-trait = "0.1.74" actix-web-actors = "4.2.0" @@ -39,6 +40,7 @@ tracing-bunyan-formatter = "0.3" tracing-actix-web = "0.7" jsonwebtoken = "9" opaque-ke = { version = "3.0.0-pre.5", features = ["argon2"] } +zeroize = "1.8.1" # Workspace dependencies tls_codec = { workspace = true } diff --git a/server/src/endpoints/qs/push_notification_provider.rs b/server/src/endpoints/qs/push_notification_provider.rs index cb33d14f..2ed72128 100644 --- a/server/src/endpoints/qs/push_notification_provider.rs +++ b/server/src/endpoints/qs/push_notification_provider.rs @@ -6,30 +6,76 @@ use async_trait::async_trait; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use phnxbackend::{ qs::{PushNotificationError, PushNotificationProvider}, - settings::ApnsSettings, + settings::{ApnsSettings, FcmSettings}, }; use phnxtypes::messages::push_token::{PushToken, PushTokenOperator}; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; +use serde_json::json; use std::{ fs::File, io::Read, - sync::{Arc, Mutex}, + sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; +use tokio::sync::Mutex; +use zeroize::Zeroize; + +#[derive(Debug, Serialize)] +struct FcmClaims { + iss: String, + scope: String, + aud: String, + iat: usize, + exp: usize, +} + +// Struct for the Google OAuth2 response +#[derive(Debug, Deserialize)] +struct OauthTokenResponse { + access_token: String, + _token_type: String, + expires_in: u64, +} #[derive(Debug, Serialize, Deserialize)] -struct Claims { +struct ApnsClaims { iss: String, iat: usize, } #[derive(Debug, Clone)] -pub struct ApnsToken { +struct ApnsToken { jwt: String, issued_at: u64, } +#[derive(Debug, Clone, Zeroize)] +struct FcmToken { + token: String, + expires_at: u64, // Seconds since UNIX_EPOCH +} + +impl FcmToken { + fn token(&self) -> &str { + &self.token + } + + fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + now >= self.expires_at + } +} + +#[derive(Debug, Clone)] +struct FcmState { + service_account: ServiceAccount, + token: Arc>>, +} + #[derive(Debug, Clone)] pub struct ApnsState { pub key_id: String, @@ -38,44 +84,143 @@ pub struct ApnsState { token: Arc>>, } +#[derive(Debug, Clone, Serialize, Deserialize, Zeroize)] +pub struct ServiceAccount { + #[serde(rename = "type")] + pub key_type: Option, + pub project_id: Option, + pub private_key_id: Option, + pub private_key: String, + pub client_email: String, + pub client_id: Option, + pub auth_uri: Option, + pub token_uri: String, + pub auth_provider_x509_cert_url: Option, + pub client_x509_cert_url: Option, + pub universe_domain: Option, +} + #[derive(Debug, Clone)] pub struct ProductionPushNotificationProvider { + fcm_state: Option, apns_state: Option, } impl ProductionPushNotificationProvider { - // Create a new ProductionPushNotificationProvider. If the config_option is - // None, the provider will effectively not send push notifications. - pub fn new(config_option: Option) -> Result> { - let Some(config) = config_option else { - return Ok(Self { apns_state: None }); + // Create a new ProductionPushNotificationProvider. If the settings are + // None, the provider will effectively not send push notifications for that + // platform. + pub fn new( + fcm_settings: Option, + apns_settings: Option, + ) -> Result> { + // Read the FCN service account file + let fcm_state = if let Some(fcm_settings) = fcm_settings { + let service_account = std::fs::read_to_string(fcm_settings.path)?; + + Some(FcmState { + service_account: serde_json::from_str(&service_account)?, + token: Arc::new(Mutex::new(None)), + }) + } else { + None }; - // Read the private key - let mut private_key_file = File::open(&config.privatekeypath)?; - let mut private_key_p8 = String::new(); - private_key_file.read_to_string(&mut private_key_p8)?; - Ok(Self { - apns_state: Some(ApnsState { - key_id: config.keyid, - team_id: config.teamid, - private_key: private_key_p8.as_bytes().to_vec(), + // Read the parameters for APNS + let apns_state = if let Some(apns_settings) = apns_settings { + // Read the private key + let mut private_key_file = File::open(&apns_settings.privatekeypath)?; + let mut private_key_p8 = String::new(); + private_key_file.read_to_string(&mut private_key_p8)?; + + Some(ApnsState { + key_id: apns_settings.keyid, + team_id: apns_settings.teamid, + private_key: private_key_p8.into_bytes(), token: Arc::new(Mutex::new(None)), - }), + }) + } else { + None + }; + + Ok(Self { + fcm_state, + apns_state, }) } - /// Return the JWT. If the token is older than 40 minutes, a new token is - /// issued (as JWTs must be between 20 and 60 minutes old). - fn issue_jwt(&self) -> Result> { + async fn issue_fcm_token(&self) -> Result> { + // TODO #237: Proactively refresh the token before it expires + let fcm_state = self.fcm_state.as_ref().ok_or("Missing Service Account")?; + + // Check whether we already have a token and if it is still valid + let mut token_option = fcm_state.token.lock().await; + if let Some(token) = token_option.as_ref() { + if !token.is_expired() { + return Ok(token.clone()); + } + } + + let service_account = &fcm_state.service_account; + + // Extract necessary fields from the service account + let private_key = &service_account.private_key; + let client_email = &service_account.client_email; + + // Generate JWT claims + let iat = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as usize; + let exp = iat + 3600; // Token valid for 1 hour + + let claims = FcmClaims { + iss: client_email.to_string(), + scope: "https://www.googleapis.com/auth/firebase.messaging".to_string(), + aud: "https://oauth2.googleapis.com/token".to_string(), + iat, + exp, + }; + + // Create the JWT + let header = Header::new(Algorithm::RS256); + let encoding_key = EncodingKey::from_rsa_pem(private_key.as_bytes())?; + let jwt = encode(&header, &claims, &encoding_key)?; + + // Send the JWT to Google's OAuth2 token endpoint and get a bearer token + // back + let client = Client::new(); + let response = client + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", &jwt), + ]) + .send() + .await?; + + let token_response: OauthTokenResponse = response.json().await?; + + // Create the FcmToken + let fcm_token = FcmToken { + token: token_response.access_token, + // Save the expiration time + expires_at: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + + token_response.expires_in, + }; + + // Store the token + *token_option = Some(fcm_token.clone()); + + Ok(fcm_token) + } + + /// Return a JWT for APNS. If the token is older than 40 minutes, a new + /// token is issued (as JWTs must be between 20 and 60 minutes old). + async fn issue_apns_jwt(&self) -> Result> { + // TODO #237: Proactively refresh the jwt before it expires let apns_state = self.apns_state.as_ref().ok_or("Missing ApnsState")?; // Check whether we already have a token and if it is still valid, i.e. // not older than 40 minutes - let mut token_option = apns_state - .token - .lock() - .map_err(|_| "Could not lock token mutex")?; + let mut token_option = apns_state.token.lock().await; if let Some(token) = &*token_option { let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); @@ -87,7 +232,7 @@ impl ProductionPushNotificationProvider { let iat = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as usize; // Create the JWT claims - let claims = Claims { + let claims = ApnsClaims { iss: apns_state.team_id.clone(), iat, }; @@ -111,77 +256,140 @@ impl ProductionPushNotificationProvider { Ok(token) } + + async fn push_google(&self, push_token: PushToken) -> Result<(), PushNotificationError> { + // If we don't have an FCM state, we can't send push notifications + let Some(fcm_state) = &self.fcm_state else { + return Ok(()); + }; + + let service_account = &fcm_state.service_account; + + let bearer_token = self + .issue_fcm_token() + .await + .map_err(|e| PushNotificationError::OAuthError(e.to_string()))?; + + // Extract the project ID from the service account + let Some(ref project_id) = service_account.project_id else { + return Err(PushNotificationError::InvalidConfiguration( + "Missing project ID in service account".to_string(), + )); + }; + + // Create the URL + let url = format!("https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"); + + // Construct the message payload + let message = json!({ + "message": { + "token": push_token.token(), + "data": { + "id": "", + } + } + }); + + // Send the request + let client = Client::new(); + let res = client + .post(&url) + .bearer_auth(bearer_token.token()) + .json(&message) + .send() + .await + .map_err(|e| PushNotificationError::NetworkError(e.to_string()))?; + + match res.status() { + StatusCode::OK => Ok(()), + // If the token is invalid, we might want to know it and + // delete it + StatusCode::NOT_FOUND => Err(PushNotificationError::InvalidToken( + res.text().await.unwrap_or_default(), + )), + // If the status code is not OK or NOT_FOUND, we might want to + // log the error + s => Err(PushNotificationError::Other(format!( + "Unexpected status code: {} with body: {}", + s, + res.text().await.unwrap_or_default() + ))), + } + } + + async fn push_apple(&self, push_token: PushToken) -> Result<(), PushNotificationError> { + // If we don't have an APNS state, we can't send push notifications + if self.apns_state.is_none() { + return Ok(()); + } + + // Issue the JWT + let jwt = self + .issue_apns_jwt() + .await + .map_err(|e| PushNotificationError::JwtCreationError(e.to_string()))?; + + // Create the URL + let url = format!( + "https://api.push.apple.com:443/3/device/{}", + push_token.token() + ); + + // Create the headers and payload + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("authorization", format!("bearer {}", jwt).parse().unwrap()); + headers.insert("apns-topic", "im.phnx.prototype".parse().unwrap()); + headers.insert("apns-push-type", "alert".parse().unwrap()); + headers.insert("apns-priority", "10".parse().unwrap()); + headers.insert("apns-expiration", "0".parse().unwrap()); + + let body = r#" + { + "aps": { + "alert": { + "title": "Empty notification", + "body": "This artefact should disappear once the app is in public beta." + }, + "mutable-content": 1 + }, + "data": "data", + } + "#; + + // Send the push notification + let client = Client::new(); + let res = client + .post(url) + .headers(headers) + .body(body) + .send() + .await + .map_err(|e| PushNotificationError::NetworkError(e.to_string()))?; + + match res.status() { + StatusCode::OK => Ok(()), + // If the token is invalid, we might want to know it and + // delete it + StatusCode::GONE => Err(PushNotificationError::InvalidToken( + res.text().await.unwrap_or_default(), + )), + // If the status code is not OK or GONE, we might want to + // log the error + s => Err(PushNotificationError::Other(format!( + "Unexpected status code: {} with body: {}", + s, + res.text().await.unwrap_or_default() + ))), + } + } } #[async_trait] impl PushNotificationProvider for ProductionPushNotificationProvider { async fn push(&self, push_token: PushToken) -> Result<(), PushNotificationError> { match push_token.operator() { - PushTokenOperator::Apple => { - // If we don't have an APNS state, we can't send push notifications - if self.apns_state.is_none() { - return Ok(()); - } - - // Issue the JWT - let jwt = self - .issue_jwt() - .map_err(|e| PushNotificationError::JwtCreationError(e.to_string()))?; - - // Create the URL - let url = format!( - "https://api.push.apple.com:443/3/device/{}", - push_token.token() - ); - - // Create the headers and payload - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert("authorization", format!("bearer {}", jwt).parse().unwrap()); - headers.insert("apns-topic", "im.phnx.prototype".parse().unwrap()); - headers.insert("apns-push-type", "alert".parse().unwrap()); - headers.insert("apns-priority", "10".parse().unwrap()); - headers.insert("apns-expiration", "0".parse().unwrap()); - - let body = r#" - { - "aps": { - "alert": { - "title": "Empty notification", - "body": "Please report this issue" - }, - "mutable-content": 1 - }, - "data": "data", - } - "#; - - // Send the push notification - let client = Client::new(); - let res = client - .post(url) - .headers(headers) - .body(body) - .send() - .await - .map_err(|e| PushNotificationError::NetworkError(e.to_string()))?; - - match res.status() { - StatusCode::OK => Ok(()), - // If the token is invalid, we might want to know it and - // delete it - StatusCode::GONE => Err(PushNotificationError::InvalidToken( - res.text().await.unwrap_or_default(), - )), - // If the status code is not OK or GONE, we might want to - // log the error - s => Err(PushNotificationError::Other(format!( - "Unexpected status code: {} with body: {}", - s, - res.text().await.unwrap_or_default() - ))), - } - } - PushTokenOperator::Google => Err(PushNotificationError::UnsupportedType), + PushTokenOperator::Apple => self.push_apple(push_token).await, + PushTokenOperator::Google => self.push_google(push_token).await, } } } diff --git a/server/src/main.rs b/server/src/main.rs index 578f6be7..6b64e22b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -81,8 +81,9 @@ async fn main() -> std::io::Result<()> { .expect("Failed to connect to database."); let ws_dispatch_notifier = DispatchWebsocketNotifier::default_addr(); - let push_notification_provider = ProductionPushNotificationProvider::new(configuration.apns) - .map_err(|e| std::io::Error::other(e.to_string()))?; + let push_notification_provider = + ProductionPushNotificationProvider::new(configuration.fcm, configuration.apns) + .map_err(|e| std::io::Error::other(e.to_string()))?; let qs_connector = SimpleEnqueueProvider { qs: qs.clone(), notifier: ws_dispatch_notifier.clone(), diff --git a/test_harness/src/utils/mod.rs b/test_harness/src/utils/mod.rs index 9ae4714e..6faed748 100644 --- a/test_harness/src/utils/mod.rs +++ b/test_harness/src/utils/mod.rs @@ -82,7 +82,7 @@ pub async fn spawn_app( .await .expect("Failed to connect to database."); - let push_notification_provider = ProductionPushNotificationProvider::new(None).unwrap(); + let push_notification_provider = ProductionPushNotificationProvider::new(None, None).unwrap(); let qs_connector = SimpleEnqueueProvider { qs: qs.clone(),