Skip to content

Commit eded3d1

Browse files
authored
feat: Add Web Push support
1 parent e1f7af6 commit eded3d1

File tree

8 files changed

+423
-78
lines changed

8 files changed

+423
-78
lines changed

Cargo.lock

Lines changed: 247 additions & 55 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ serde_json = "1.0.122"
2323
sled = "0.34.2"
2424
structopt = "0.3.15"
2525
tokio = { version = "1.39.2", features = ["full"] }
26+
web-push-native = "0.4.0"
2627
yup-oauth2 = "9.0.0"
2728

2829
[dev-dependencies]

README.md

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,50 @@ that forwards "device tokens" to Apple and Google "Push Services"
88
that in turn wake up the clients
99
using [Chatmail core](https://github.com/chatmail/core/) on user's devices.
1010

11-
## Usage
11+
## Usage
1212

13-
### Certificates
13+
### OpenPGP key
14+
15+
The OpenPGP key can be generated using [rsop](https://codeberg.org/heiko/rsop):
16+
17+
```console
18+
$ rsop generate-key --profile rfc9580 > openpgp.privkey
19+
$ rsop extract-cert < privkey > openpgp.pubkey
20+
```
21+
22+
### APNS Certificates
1423

1524
The certificate file provided must be a `.p12` file. Instructions for how to create can be found [here](https://stackoverflow.com/a/28962937/1358405).
1625

26+
27+
### FCM token
28+
29+
The FCM token can be retrieved in the Firebase console.
30+
31+
### VAPID key
32+
33+
The VAPID key can be generated with openssl. The VAPID public key will be printed during startup:
34+
35+
```console
36+
$ openssl ecparam -name prime256v1 -genkey -noout -out vapid.privkey
37+
$ openssl pkcs8 -topk8 -in vapid.privkey -nocrypt -out vapid.pk8
38+
```
39+
1740
### Running
1841

19-
```sh
42+
```console
2043
$ cargo build --release
21-
$ ./target/release/notifiers --certificate-file <file.p12> --password <password>
44+
$ ./target/release/notifiers --certificate-file <file.p12> --password <password> --fcm-key-path <fcm.private> --openpgp-keyring-path <openpgp.privkey> --vapid-key-path <vapid.pk8>
2245
```
2346

47+
- `file.p12` is APNS certificate
48+
- `password` is file.p12 password
49+
- `fcm.private` is the FCM token
50+
- `openpgp.privkey` is the generated OpenPGP key
51+
2452
### Registering devices
2553

26-
```sh
54+
```console
2755
$ curl -X POST -d '{ "token": "<device token>" }' http://localhost:9000/register
2856
```
2957

src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ struct Opt {
3636
#[structopt(long)]
3737
fcm_key_path: String,
3838

39+
/// Path to VAPID private key.
40+
#[structopt(long)]
41+
vapid_key_path: String,
42+
3943
/// Path to the OpenPGP private keyring.
4044
///
4145
/// OpenPGP keys are used to decrypt tokens
@@ -67,6 +71,7 @@ async fn main() -> Result<()> {
6771
metrics_state,
6872
opt.interval,
6973
opt.fcm_key_path,
74+
opt.vapid_key_path,
7075
opt.openpgp_keyring_path,
7176
)
7277
.await?;

src/metrics.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ pub struct Metrics {
3030
/// Number of successfully sent visible UBports notifications.
3131
pub ubports_notifications_total: Counter,
3232

33+
/// Number of successfully sent visible web push notifications.
34+
pub webpush_notifications_total: Counter,
35+
3336
/// Number of debounced notifications.
3437
pub debounced_notifications_total: Counter,
3538

@@ -74,6 +77,13 @@ impl Metrics {
7477
ubports_notifications_total.clone(),
7578
);
7679

80+
let webpush_notifications_total = Counter::default();
81+
registry.register(
82+
"webpush_notifications",
83+
"Number of web push notifications",
84+
ubports_notifications_total.clone(),
85+
);
86+
7787
let debounced_notifications_total = Counter::default();
7888
registry.register(
7989
"debounced_notifications",
@@ -121,6 +131,7 @@ impl Metrics {
121131
direct_notifications_total,
122132
fcm_notifications_total,
123133
ubports_notifications_total,
134+
webpush_notifications_total,
124135
debounced_notifications_total,
125136
debounced_set_size,
126137
heartbeat_notifications_total,

src/notifier.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ async fn wakeup(
8585
let device_token: NotificationToken = key_device_token.as_str().parse()?;
8686

8787
let (client, device_token) = match device_token {
88-
NotificationToken::Fcm { .. } | NotificationToken::UBports(..) => {
88+
NotificationToken::Fcm { .. }
89+
| NotificationToken::UBports(..)
90+
| NotificationToken::WebPush { .. } => {
8991
// Only APNS tokens can be registered for periodic notifications.
9092
info!("Removing FCM token {key_device_token}");
9193
schedule

src/server.rs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ use anyhow::{bail, Error, Result};
66
use axum::http::StatusCode;
77
use axum::response::{IntoResponse, Response};
88
use axum::routing::{get, post};
9+
use base64::Engine as _;
910
use chrono::{Local, TimeDelta};
1011
use log::*;
1112
use serde::Deserialize;
1213
use std::str::FromStr;
1314
use std::time::Instant;
15+
use web_push_native::jwt_simple::prelude::ES256KeyPair;
16+
use web_push_native::{p256, Auth, WebPushBuilder};
1417

1518
use crate::metrics::Metrics;
1619
use crate::state::State;
@@ -81,6 +84,16 @@ pub(crate) enum NotificationToken {
8184
/// Ubuntu touch app
8285
UBports(String),
8386

87+
/// Web Push - for UnifiedPush
88+
WebPush {
89+
/// Push endpoint to send to
90+
endpoint: String,
91+
/// UA Public key in the uncompressed form, URL-safe Base64 encoded without padding
92+
ua_public_key: String,
93+
/// Authentication secret from the UA, URL-safe Base64 encoded without padding
94+
ua_auth: String,
95+
},
96+
8497
/// Android App.
8598
Fcm {
8699
/// Package name such as `chat.delta`.
@@ -112,6 +125,21 @@ impl FromStr for NotificationToken {
112125
}
113126
} else if let Some(s) = s.strip_prefix("ubports-") {
114127
Ok(Self::UBports(s.to_string()))
128+
} else if let Some(s) = s.strip_prefix("webpush:") {
129+
let mut iter = s.splitn(3, '|');
130+
if let (Some(endpoint), Some(ua_public_key), Some(ua_auth)) = (
131+
iter.next().map(|x| x.to_string()),
132+
iter.next().map(|x| x.to_string()),
133+
iter.next().map(|x| x.to_string()),
134+
) {
135+
Ok(Self::WebPush {
136+
endpoint,
137+
ua_public_key,
138+
ua_auth,
139+
})
140+
} else {
141+
bail!("Invalid web push token");
142+
}
115143
} else if let Some(token) = s.strip_prefix("sandbox:") {
116144
Ok(Self::ApnsSandbox(token.to_string()))
117145
} else {
@@ -120,6 +148,47 @@ impl FromStr for NotificationToken {
120148
}
121149
}
122150

151+
/// Notify Web Push endpoint
152+
///
153+
/// Defined by 3 RFC:
154+
/// - Server to Server API in [RFC8030](https://www.rfc-editor.org/rfc/rfc8030)
155+
/// - Encryption in [RFC8291](https://www.rfc-editor.org/rfc/rfc8291)
156+
/// - Authorization in [RFC8292](https://www.rfc-editor.org/rfc/rfc8292) (VAPID)
157+
async fn notify_webpush(
158+
client: &reqwest::Client,
159+
vapid_key: &ES256KeyPair,
160+
endpoint: &str,
161+
ua_public: &str,
162+
ua_auth: &str,
163+
metrics: &Metrics,
164+
) -> Result<StatusCode> {
165+
let request = WebPushBuilder::new(
166+
endpoint.parse()?,
167+
p256::PublicKey::from_sec1_bytes(
168+
&base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ua_public)?,
169+
)?,
170+
Auth::clone_from_slice(&base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ua_auth)?),
171+
)
172+
.with_vapid(vapid_key, "https://github.com/chatmail/notifiers/issues")
173+
.build("ping")?;
174+
175+
let res = client
176+
.post(endpoint)
177+
.headers(request.headers().clone())
178+
.body(request.into_body())
179+
.send()
180+
.await?;
181+
metrics.webpush_notifications_total.inc();
182+
183+
let status = res.status();
184+
// Map web push responses to chatmail/relay notifier values
185+
match status.as_u16() {
186+
201 => Ok(StatusCode::OK),
187+
404 | 403 | 401 => Ok(StatusCode::GONE),
188+
_ => Ok(status),
189+
}
190+
}
191+
123192
/// Notify the UBports push server
124193
///
125194
/// API documentation is available at
@@ -312,16 +381,33 @@ async fn notify_device(
312381
let device_token: NotificationToken = device_token.as_str().parse()?;
313382

314383
let status_code = match device_token {
384+
NotificationToken::WebPush {
385+
endpoint,
386+
ua_public_key,
387+
ua_auth,
388+
} => {
389+
let client = state.http_client().clone();
390+
let metrics = state.metrics();
391+
notify_webpush(
392+
&client,
393+
state.vapid_key(),
394+
&endpoint,
395+
&ua_public_key,
396+
&ua_auth,
397+
metrics,
398+
)
399+
.await?
400+
}
315401
NotificationToken::UBports(token) => {
316-
let client = state.fcm_client().clone();
402+
let client = state.http_client().clone();
317403
let metrics = state.metrics();
318404
notify_ubports(&client, &token, metrics).await?
319405
}
320406
NotificationToken::Fcm {
321407
package_name,
322408
token,
323409
} => {
324-
let client = state.fcm_client().clone();
410+
let client = state.http_client().clone();
325411
let Ok(fcm_token) = state.fcm_token().await else {
326412
return Ok(StatusCode::INTERNAL_SERVER_ERROR);
327413
};

src/state.rs

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ use std::time::Duration;
55

66
use a2::{Client, Endpoint};
77
use anyhow::{Context as _, Result};
8+
use base64::Engine as _;
9+
use web_push_native::jwt_simple::prelude::ECDSAP256PublicKeyLike as _;
10+
use web_push_native::p256::pkcs8::DecodePrivateKey as _;
811

912
use crate::debouncer::Debouncer;
1013
use crate::metrics::Metrics;
@@ -19,11 +22,11 @@ pub struct State {
1922
pub struct InnerState {
2023
schedule: Schedule,
2124

22-
fcm_client: reqwest::Client,
25+
http_client: reqwest::Client,
2326

24-
production_client: Client,
27+
apns_production_client: Client,
2528

26-
sandbox_client: Client,
29+
apns_sandbox_client: Client,
2730

2831
topic: Option<String>,
2932

@@ -34,6 +37,8 @@ pub struct InnerState {
3437

3538
fcm_authenticator: yup_oauth2::authenticator::DefaultAuthenticator,
3639

40+
vapid_key: web_push_native::jwt_simple::prelude::ES256KeyPair,
41+
3742
/// Decryptor for incoming tokens
3843
/// storing the secret keyring inside.
3944
openpgp_decryptor: PgpDecryptor,
@@ -51,13 +56,14 @@ impl State {
5156
metrics: Metrics,
5257
interval: Duration,
5358
fcm_key_path: String,
59+
vapid_key_path: String,
5460
openpgp_keyring_path: String,
5561
) -> Result<Self> {
5662
let schedule = Schedule::new(db)?;
57-
let fcm_client = reqwest::ClientBuilder::new()
63+
let http_client = reqwest::ClientBuilder::new()
5864
.timeout(Duration::from_secs(60))
5965
.build()
60-
.context("Failed to build FCM client")?;
66+
.context("Failed to build HTTP client (FCM/UBPorts/WebPush)")?;
6167

6268
let fcm_key: yup_oauth2::ServiceAccountKey =
6369
yup_oauth2::read_service_account_key(fcm_key_path)
@@ -68,12 +74,21 @@ impl State {
6874
.await
6975
.context("Failed to create authenticator")?;
7076

71-
let production_client =
77+
let apns_production_client =
7278
Client::certificate(&mut certificate, password, Endpoint::Production)
7379
.context("Failed to create production client")?;
7480
certificate.rewind()?;
75-
let sandbox_client = Client::certificate(&mut certificate, password, Endpoint::Sandbox)
76-
.context("Failed to create sandbox client")?;
81+
let apns_sandbox_client =
82+
Client::certificate(&mut certificate, password, Endpoint::Sandbox)
83+
.context("Failed to create sandbox client")?;
84+
85+
let p256_sk =
86+
web_push_native::p256::ecdsa::SigningKey::read_pkcs8_pem_file(&vapid_key_path)?;
87+
let vapid_key =
88+
web_push_native::jwt_simple::prelude::ES256KeyPair::from_bytes(&p256_sk.to_bytes())?;
89+
let vapid_pubkey = &base64::engine::general_purpose::URL_SAFE_NO_PAD
90+
.encode(vapid_key.public_key().public_key().to_bytes_uncompressed());
91+
log::warn!("VAPID pubkey={vapid_pubkey}");
7792

7893
let mut keyring_file = std::fs::File::open(openpgp_keyring_path)?;
7994
let mut keyring = String::new();
@@ -83,13 +98,14 @@ impl State {
8398
Ok(State {
8499
inner: Arc::new(InnerState {
85100
schedule,
86-
fcm_client,
87-
production_client,
88-
sandbox_client,
101+
http_client,
102+
apns_production_client,
103+
apns_sandbox_client,
89104
topic,
90105
metrics,
91106
interval,
92107
fcm_authenticator,
108+
vapid_key,
93109
openpgp_decryptor,
94110
debouncer: Default::default(),
95111
}),
@@ -100,8 +116,8 @@ impl State {
100116
&self.inner.schedule
101117
}
102118

103-
pub fn fcm_client(&self) -> &reqwest::Client {
104-
&self.inner.fcm_client
119+
pub fn http_client(&self) -> &reqwest::Client {
120+
&self.inner.http_client
105121
}
106122

107123
pub async fn fcm_token(&self) -> Result<Option<String>> {
@@ -115,12 +131,16 @@ impl State {
115131
Ok(token)
116132
}
117133

134+
pub fn vapid_key(&self) -> &web_push_native::jwt_simple::prelude::ES256KeyPair {
135+
&self.inner.vapid_key
136+
}
137+
118138
pub fn production_client(&self) -> &Client {
119-
&self.inner.production_client
139+
&self.inner.apns_production_client
120140
}
121141

122142
pub fn sandbox_client(&self) -> &Client {
123-
&self.inner.sandbox_client
143+
&self.inner.apns_sandbox_client
124144
}
125145

126146
pub fn topic(&self) -> Option<&str> {

0 commit comments

Comments
 (0)