feat(relay): ephemeral delivery, call wake-ups and TURN credentials

Protocol and relay groundwork for typing indicators and 1:1 calls.

- ContentHint.EPHEMERAL: delivered only to an online recipient, never
  queued or pushed; the sender gets Receipt.STATUS_DROPPED otherwise.
- ContentHint.CALL: a call offer is queued like a normal message, but an
  offline recipient is woken immediately — a PushKit VoIP push on iOS
  (priority 10, 45 s expiry) or a short-lived FCM data message on
  Android. The relay learns that a device is being called, not by whom.
- RegisterPush.voip_token stores the iOS VoIP token next to the regular
  APNs token (migration 0004).
- TurnRequest/TurnCredentials: logged-in devices receive time-limited
  coturn credentials (use-auth-secret REST scheme, HMAC-SHA1), so the
  TURN server is never an open relay. Configured with SEPOT_TURN_URLS and
  SEPOT_TURN_SECRET or SEPOT_TURN_SECRET_FILE; without them calls fall
  back to direct connections and STUN.
This commit is contained in:
Julius Joska
2026-10-03 22:43:53 +02:00
parent 4e3d7de32c
commit 69687d0acf
14 changed files with 698 additions and 27 deletions
+49
View File
@@ -35,6 +35,11 @@ fn wake_expiration(now: DateTime<Utc>) -> i64 {
(now + chrono::Duration::days(crate::state::QUEUE_TTL_DAYS)).timestamp()
}
/// Jak dlouho může zvonit hovor, který telefon nezastihl hned. Stejná mez
/// jako u klienta: nabídka starší než tohle se už nezvoní, jen zapíše jako
/// zmeškaná.
pub const CALL_RING_SECONDS: i64 = 45;
#[derive(Serialize)]
struct Claims<'a> {
iss: &'a str,
@@ -148,6 +153,50 @@ impl ApnsSender {
_ => {}
}
}
/// VoIP push (PushKit) pro příchozí hovor. iOS po něm MUSÍ hned ukázat
/// hovor v CallKit — to zařídí appka. Topic je `<bundle>.voip`, priorita
/// 10 (u typu voip povolená) a krátká expirace: zvonit po minutě nemá
/// smysl.
pub async fn send_voip_call(&self, client: &reqwest::Client, voip_token: &str) {
let Some(jwt) = self.auth_token().await else {
return;
};
let url = format!("{}/3/device/{}", self.host, voip_token);
let expiration = Utc::now().timestamp() + CALL_RING_SECONDS;
let resp = client
.post(&url)
.header("authorization", format!("bearer {jwt}"))
.header("apns-topic", format!("{}.voip", self.topic))
.header("apns-push-type", "voip")
.header("apns-priority", "10")
.header("apns-expiration", expiration.to_string())
.body(r#"{"aps":{},"k":"call"}"#)
.send()
.await;
match resp {
Ok(r) if !r.status().is_success() => {
tracing::warn!(status = %r.status(), "APNs VoIP push odmítnut");
}
Err(e) => tracing::warn!("APNs VoIP push selhal: {e}"),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn budicek_drzi_apns_stejne_dlouho_jako_fronta() {
let ted = DateTime::parse_from_rfc3339("2026-09-29T18:00:00Z")
.unwrap()
.with_timezone(&Utc);
let limit = wake_expiration(ted) - ted.timestamp();
assert_eq!(limit, crate::state::QUEUE_TTL_DAYS * 86_400);
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
}
}
#[cfg(test)]
+27 -7
View File
@@ -129,13 +129,6 @@ impl FcmSender {
/// Pošle budíček na FCM registrační token. Best-effort; loguje se jen
/// stavový kód (nikdy token).
pub async fn send_wake(&self, http: &reqwest::Client, device_token: &str) {
let Some(access) = self.access_token(http).await else {
return;
};
let url = format!(
"https://fcm.googleapis.com/v1/projects/{}/messages:send",
self.project_id
);
let body = serde_json::json!({
"message": {
"token": device_token,
@@ -143,6 +136,33 @@ impl FcmSender {
"android": { "priority": "HIGH" }
}
});
self.send(http, body).await;
}
/// Budíček pro příchozí hovor: `k=call` spustí na telefonu zvonění
/// a platnost je krátká — zmeškaný hovor po návratu do sítě nezvoní.
pub async fn send_call_wake(&self, http: &reqwest::Client, device_token: &str) {
let body = serde_json::json!({
"message": {
"token": device_token,
"data": { "v": "1", "k": "call" },
"android": {
"priority": "HIGH",
"ttl": format!("{}s", crate::apns::CALL_RING_SECONDS)
}
}
});
self.send(http, body).await;
}
async fn send(&self, http: &reqwest::Client, body: serde_json::Value) {
let Some(access) = self.access_token(http).await else {
return;
};
let url = format!(
"https://fcm.googleapis.com/v1/projects/{}/messages:send",
self.project_id
);
// warn, ne debug: relay běží na RUST_LOG=info, takže na debugu bylo
// selhání pushe neviditelné a tiše se hromadila fronta. Protistranou
// je Google, ne telefon uživatele — chyba tu znamená mrtvý token nebo
+1
View File
@@ -13,6 +13,7 @@ mod net;
mod push;
mod rest;
mod state;
mod turn;
mod ws;
use std::net::SocketAddr;
+40
View File
@@ -3,6 +3,10 @@
//! Klient se po probuzení sám připojí na WS a vyzvedne frontu sealed-sender
//! kanálem. Endpoint se nikde neloguje.
//!
//! Jediná výjimka je příchozí hovor (`ContentHint::Call`): budíček nese
//! `"k":"call"`, aby telefon začal zvonit hned (CallKit / celoobrazovkové
//! zvonění) místo tichého probuzení. Kdo volá, v něm dál není.
//!
//! Podporované cesty:
//! - UnifiedPush (3) / WebPush (4): HTTP POST na endpoint URL distributora.
//! - FCM (1) / APNs (2): opt-in „fast mode" — vyžaduje externí credentials
@@ -31,6 +35,42 @@ pub const PLATFORM_MAX: i16 = 4;
/// není co chránit.
const WAKE_BODY: &str = "{\"v\":1}";
/// Push cíl zařízení včetně iOS VoIP tokenu (jen pro hovory).
pub struct PushTarget {
pub platform: i16,
pub token: Option<Vec<u8>>,
pub voip_token: Option<Vec<u8>>,
}
/// Budíček pro příchozí hovor. iPhone s VoIP tokenem → VoIP push (jediná
/// cesta, jak iOS nechá zazvonit zabitou appku); Android FCM → budíček
/// `k=call` s krátkou platností; ostatní (UnifiedPush, starší iOS appka)
/// → běžný budíček, zazvoní až po vyzvednutí fronty.
pub async fn send_call_wake(
client: &Client,
target: &PushTarget,
fcm: Option<&crate::fcm::FcmSender>,
apns: Option<&crate::apns::ApnsSender>,
) {
if let (Some(sender), Some(Ok(voip))) = (
apns,
target.voip_token.as_deref().map(std::str::from_utf8),
) {
sender.send_voip_call(client, voip).await;
return;
}
let Some(token) = target.token.as_deref() else {
return;
};
if target.platform == PLATFORM_FCM {
if let (Some(sender), Ok(device_token)) = (fcm, std::str::from_utf8(token)) {
sender.send_call_wake(client, device_token).await;
}
return;
}
send_wake(client, target.platform, token, fcm, apns).await;
}
/// HTTP klient pro budíčky: bez redirectů (anti-SSRF přes 3xx), krátký
/// timeout, generický UA.
pub fn build_client() -> Client {
+170 -6
View File
@@ -124,6 +124,8 @@ pub struct AppState {
pub fcm: Option<std::sync::Arc<crate::fcm::FcmSender>>,
/// APNs nativní iOS push (None = nenakonfigurováno).
pub apns: Option<std::sync::Arc<crate::apns::ApnsSender>>,
/// TURN pro hovory (None = relay údaje nevydává).
pub turn: Option<crate::turn::TurnConfig>,
}
impl AppState {
@@ -144,6 +146,7 @@ impl AppState {
attachments,
fcm: crate::fcm::FcmSender::from_env(),
apns: crate::apns::ApnsSender::from_env(),
turn: crate::turn::TurnConfig::from_env(),
}
}
@@ -162,6 +165,10 @@ impl AppState {
anyhow::bail!("neplatná obálka");
}
if env.content_hint == v1::ContentHint::Ephemeral as i32 {
return Ok(self.route_ephemeral(env).await);
}
let dst = env.dst_address.clone();
let encoded = env.encode_to_vec();
let expires = Utc::now() + Duration::days(QUEUE_TTL_DAYS);
@@ -179,6 +186,7 @@ impl AppState {
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
self.trim_queue(&dst, MAX_QUEUE_BYTES_PER_RECIPIENT).await?;
let is_call = env.content_hint == v1::ContentHint::Call as i32;
if let Some(dev) = self.online.get(&dst) {
let deliver = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)),
@@ -194,10 +202,34 @@ impl AppState {
}
}
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
self.wake(&dst).await;
if is_call {
self.wake_call(&dst).await;
} else {
self.wake(&dst).await;
}
Ok(v1::receipt::Status::StoredOffline)
}
/// Jen živé doručení („píše…“): do fronty nic, push nic. Zatuchlé
/// spojení se nepočítá — obálka by v mrtvém kanálu zmizela stejně
/// a odesílatel se má dozvědět, že příjemce nikdo neposlouchá.
async fn route_ephemeral(&self, env: v1::Envelope) -> v1::receipt::Status {
let Some(dev) = self.online.get(&env.dst_address) else {
return v1::receipt::Status::Dropped;
};
if !dev.is_fresh(self.now_ms()) {
return v1::receipt::Status::Dropped;
}
let deliver = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)),
};
if dev.tx.send(deliver).await.is_ok() {
v1::receipt::Status::DeliveredOnline
} else {
v1::receipt::Status::Dropped
}
}
/// FIFO strop fronty příjemce: nech nejnovější obálky do `cap_bytes`
/// (běžný součet od nejnovější), starší přebytek zahoď. Vrací počet
/// zahozených. Oddělené kvůli přímému testu bez vkládání 50 MiB.
@@ -242,6 +274,48 @@ impl AppState {
});
}
/// Budíček pro příchozí hovor: iPhone s VoIP tokenem dostane VoIP push
/// (CallKit zazvoní i u zabité appky), ostatní běžný budíček — FCM
/// s krátkou platností, ať po hodině nezvoní hovor, který dávno skončil.
async fn wake_call(&self, dst_address: &[u8]) {
let Some(target) = self.push_target_full(dst_address).await else {
return;
};
let client = self.http.clone();
let fcm = self.fcm.clone();
let apns = self.apns.clone();
tokio::spawn(async move {
crate::push::send_call_wake(&client, &target, fcm.as_deref(), apns.as_deref())
.await;
});
}
/// Push cíl včetně VoIP tokenu (hovory).
pub(crate) async fn push_target_full(
&self,
dst_address: &[u8],
) -> Option<crate::push::PushTarget> {
let row: Option<(i16, Option<Vec<u8>>, Option<Vec<u8>>)> = sqlx::query_as(
"SELECT p.platform, p.token, p.voip_token FROM push_tokens p
JOIN devices d USING (device_uuid)
WHERE d.delivery_address = $1",
)
.bind(dst_address)
.fetch_optional(&self.pool)
.await
.ok()
.flatten();
let (platform, token, voip_token) = row?;
if token.is_none() && voip_token.is_none() {
return None;
}
Some(crate::push::PushTarget {
platform,
token,
voip_token,
})
}
/// Dohledá push cíl (platform, token) pro delivery_address příjemce.
/// `pub(crate)` kvůli integračnímu testu correctness JOINu.
pub(crate) async fn push_target(&self, dst_address: &[u8]) -> Option<(i16, Vec<u8>)> {
@@ -268,6 +342,7 @@ impl AppState {
device_uuid: &[u8],
platform: i16,
endpoint: Option<&[u8]>,
voip_token: Option<&[u8]>,
) -> anyhow::Result<()> {
if platform == crate::push::PLATFORM_NONE || endpoint.is_none() {
sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1")
@@ -277,16 +352,18 @@ impl AppState {
return Ok(());
}
sqlx::query(
"INSERT INTO push_tokens (device_uuid, platform, token, updated_at)
VALUES ($1, $2, $3, now())
"INSERT INTO push_tokens (device_uuid, platform, token, voip_token, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (device_uuid)
DO UPDATE SET platform = excluded.platform,
token = excluded.token,
voip_token = excluded.voip_token,
updated_at = now()",
)
.bind(device_uuid)
.bind(platform)
.bind(endpoint)
.bind(voip_token)
.execute(&self.pool)
.await?;
Ok(())
@@ -556,7 +633,7 @@ mod db_tests {
// UnifiedPush endpoint
let ep1 = b"https://ntfy.sh/UPabc".to_vec();
state
.register_push(&device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(&ep1))
.register_push(&device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(&ep1), None)
.await
.unwrap();
assert_eq!(
@@ -567,7 +644,7 @@ mod db_tests {
// upsert na WebPush (jiná platforma i token)
let ep2 = b"https://push.example/w".to_vec();
state
.register_push(&device_uuid, crate::push::PLATFORM_WEBPUSH, Some(&ep2))
.register_push(&device_uuid, crate::push::PLATFORM_WEBPUSH, Some(&ep2), None)
.await
.unwrap();
assert_eq!(
@@ -577,7 +654,7 @@ mod db_tests {
// NONE → smaže (anonymní režim)
state
.register_push(&device_uuid, crate::push::PLATFORM_NONE, None)
.register_push(&device_uuid, crate::push::PLATFORM_NONE, None, None)
.await
.unwrap();
assert!(state.push_target(&delivery_address).await.is_none());
@@ -585,6 +662,64 @@ mod db_tests {
cleanup(&state, &account_id, &delivery_address).await;
}
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn voip_token_se_ulozi_a_hovor_jde_do_fronty() {
let state = test_state().await;
let (account_id, device_uuid, delivery_address) = seed_device(&state).await;
state
.register_push(
&device_uuid,
crate::push::PLATFORM_APNS,
Some(b"apns-token"),
Some(b"voip-token"),
)
.await
.unwrap();
let target = state.push_target_full(&delivery_address).await.unwrap();
assert_eq!(target.platform, crate::push::PLATFORM_APNS);
assert_eq!(target.token.as_deref(), Some(&b"apns-token"[..]));
assert_eq!(target.voip_token.as_deref(), Some(&b"voip-token"[..]));
// Běžné zprávy dál budí běžný token, ne VoIP.
assert_eq!(
state.push_target(&delivery_address).await,
Some((crate::push::PLATFORM_APNS, b"apns-token".to_vec()))
);
// Nabídka hovoru offline příjemci: uloží se jako běžná obálka
// (vyzvedne si ji po probuzení), jen budíček je jiný.
let env = v1::Envelope {
dst_address: delivery_address.clone(),
msg_uuid: rand_bytes(16),
content_hint: v1::ContentHint::Call as i32,
payload: vec![0xCD; 64],
};
let status = state.route_envelope(env).await.unwrap();
assert_eq!(status, v1::receipt::Status::StoredOffline);
let queued: i64 =
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
.bind(&delivery_address)
.fetch_one(&state.pool)
.await
.unwrap();
assert_eq!(queued, 1);
// Nová registrace bez VoIP tokenu ho smaže (appka PushKit vypnula).
state
.register_push(&device_uuid, crate::push::PLATFORM_APNS, Some(b"apns-token"), None)
.await
.unwrap();
assert!(state
.push_target_full(&delivery_address)
.await
.unwrap()
.voip_token
.is_none());
cleanup(&state, &account_id, &delivery_address).await;
}
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn route_envelope_offline_ulozi_a_spusti_wake() {
@@ -598,6 +733,7 @@ mod db_tests {
&device_uuid,
crate::push::PLATFORM_UNIFIEDPUSH,
Some(b"https://localhost/up"),
None,
)
.await
.unwrap();
@@ -623,6 +759,33 @@ mod db_tests {
cleanup(&state, &account_id, &delivery_address).await;
}
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn ephemeral_offline_se_zahodi_bez_fronty() {
let state = test_state().await;
let (account_id, _device_uuid, delivery_address) = seed_device(&state).await;
let env = v1::Envelope {
dst_address: delivery_address.clone(),
msg_uuid: rand_bytes(16),
content_hint: v1::ContentHint::Ephemeral as i32,
payload: vec![0xAB; 64],
};
let status = state.route_envelope(env).await.unwrap();
assert_eq!(status, v1::receipt::Status::Dropped);
// „píše…“ pro offline příjemce nesmí skončit ve frontě
let queued: i64 =
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
.bind(&delivery_address)
.fetch_one(&state.pool)
.await
.unwrap();
assert_eq!(queued, 0);
cleanup(&state, &account_id, &delivery_address).await;
}
#[tokio::test]
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
async fn delete_identity_smaze_ucet_frontu_i_push() {
@@ -635,6 +798,7 @@ mod db_tests {
&device_uuid,
crate::push::PLATFORM_UNIFIEDPUSH,
Some(b"https://localhost/up"),
None,
)
.await
.unwrap();
+105
View File
@@ -0,0 +1,105 @@
//! Dočasné údaje k TURN serveru pro hovory (REST schéma coturnu,
//! `use-auth-secret`). Relay i coturn sdílí tajemství; coturn údaje ověří
//! sám, bez dotazu na relay, a po expiraci je odmítne.
//!
//! Údaje dostane jen přihlášené zařízení přes WS — TURN tak neslouží jako
//! otevřené přeposílání provozu pro kohokoli z internetu.
use base64::Engine;
use hmac::{Hmac, Mac};
use rand::Rng;
use sepot_proto::v1;
use sha1::Sha1;
/// Platnost údajů. Pokrývá i dlouhý hovor — coturn kontroluje expiraci jen
/// při alokaci a jejím obnovení, ne u běžícího hovoru.
pub const TTL_SECONDS: u32 = 24 * 3600;
pub struct TurnConfig {
secret: Vec<u8>,
urls: Vec<String>,
}
impl TurnConfig {
/// Tajemství z `SEPOT_TURN_SECRET_FILE` (soubor mimo compose, stejně
/// jako FCM a APNs klíče) nebo `SEPOT_TURN_SECRET`, adresy z `SEPOT_TURN_URLS`
/// (čárkou oddělené turn:/turns:). Bez nich = hovory jen napřímo / přes
/// STUN, relay údaje nevydává.
pub fn from_env() -> Option<Self> {
let secret = match std::env::var("SEPOT_TURN_SECRET_FILE") {
Ok(path) if !path.is_empty() => std::fs::read_to_string(&path)
.map_err(|e| tracing::warn!("TURN tajemství nejde přečíst: {e}"))
.ok()?
.trim()
.to_string(),
_ => std::env::var("SEPOT_TURN_SECRET").ok()?,
};
let urls: Vec<String> = std::env::var("SEPOT_TURN_URLS")
.ok()?
.split(',')
.map(str::trim)
.filter(|u| u.starts_with("turn:") || u.starts_with("turns:"))
.map(String::from)
.collect();
if secret.len() < 32 || urls.is_empty() {
tracing::warn!("TURN nenakonfigurován správně (secret < 32 znaků nebo bez URL)");
return None;
}
tracing::info!(urls = urls.len(), "TURN údaje pro hovory aktivní");
Some(Self::new(secret.into_bytes(), urls))
}
pub fn new(secret: Vec<u8>, urls: Vec<String>) -> Self {
Self { secret, urls }
}
/// Údaje platné `TTL_SECONDS` od `now_unix`. Náhodná část jména, ne id
/// zařízení — coturn si jména loguje a nemá z nich poznat, kdo volá.
pub fn credentials(&self, now_unix: i64) -> v1::TurnCredentials {
let expires = now_unix + i64::from(TTL_SECONDS);
let nonce: u64 = rand::rng().random();
let username = format!("{expires}:{nonce:016x}");
v1::TurnCredentials {
urls: self.urls.clone(),
credential: sign(&self.secret, &username),
username,
ttl_seconds: TTL_SECONDS,
}
}
}
/// base64(HMAC-SHA1(secret, username)) — přesně to, co počítá coturn.
fn sign(secret: &[u8], username: &str) -> String {
let mut mac =
Hmac::<Sha1>::new_from_slice(secret).expect("HMAC bere klíč libovolné délky");
mac.update(username.as_bytes());
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn podpis_odpovida_referencni_hodnote() {
// Referenční hodnota: printf '%s' '1700000000:abc' |
// openssl dgst -sha1 -hmac 'tajne' -binary | base64
assert_eq!(sign(b"tajne", "1700000000:abc"), "2PhtGxNe/gV7QGWYKw750CWo8fw=");
}
#[test]
fn udaje_nesou_expiraci_a_sedi_k_podpisu() {
let cfg = TurnConfig::new(b"x".repeat(32), vec!["turn:t.example:3478".into()]);
let c = cfg.credentials(1_000);
let (exp, _) = c.username.split_once(':').unwrap();
assert_eq!(exp.parse::<i64>().unwrap(), 1_000 + i64::from(TTL_SECONDS));
assert_eq!(c.credential, sign(&b"x".repeat(32), &c.username));
assert_eq!(c.urls, vec!["turn:t.example:3478".to_string()]);
}
#[test]
fn jmena_se_neopakuji() {
let cfg = TurnConfig::new(b"x".repeat(32), vec!["turn:t:3478".into()]);
assert_ne!(cfg.credentials(1).username, cfg.credentials(1).username);
}
}
+15 -1
View File
@@ -314,9 +314,13 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
} else {
let endpoint =
(!reg.endpoint.is_empty()).then_some(reg.endpoint.as_slice());
// VoIP token dává smysl jen vedle APNs (PushKit je iOS).
let voip = (platform == crate::push::PLATFORM_APNS
&& !reg.voip_token.is_empty())
.then_some(reg.voip_token.as_slice());
// Endpoint se ZÁMĚRNĚ neloguje (metadatový bod).
if let Err(e) =
state.register_push(device, platform, endpoint).await
state.register_push(device, platform, endpoint, voip).await
{
tracing::warn!("register_push selhal: {e}");
}
@@ -351,6 +355,16 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
}
}
Some(v1::client_frame::Frame::TurnRequest(_)) if authed_address.is_some() => {
let frame = match &state.turn {
Some(turn) => server_frame(v1::server_frame::Frame::TurnCredentials(
turn.credentials(chrono::Utc::now().timestamp()),
)),
None => error_frame(503, "TURN není nakonfigurován"),
};
let _ = tx.send(frame).await;
}
Some(v1::client_frame::Frame::Pong(_)) => {}
_ => {