8b452a5593
- stop counting ACK and Pong frames toward the per-connection frame window; draining a large queue used to trip the 429 limit, disconnect the client and, after eight rounds, drop the remaining envelopes - deliver to online recipients with try_send: a recipient that stops reading its socket no longer pins the sender's task and memory - throttle call wake-ups per recipient (1 per 10 s, 6 per 10 min) and ordinary wake-ups (1 per 5 s); collapse ids for APNs and FCM - POST /v1/register accepts X-Sepot-Identity-Proof and lets a new device take over an existing identity (restore from the recovery phrase) - POST /v1/keys uploads one-time prekeys and rotates signed/Kyber prekeys; PrekeysLow is sent after AUTH when fewer than 20 remain - GET /v1/keys hands out at most 30 one-time prekeys per address per hour - reqwest errors are logged without the URL; UnifiedPush POSTs pin the addresses the SSRF guard resolved; NAT64/6to4 embedded addresses are checked - absolute AUTH deadline, at most 3 AUTH attempts, length limits on push endpoints and VoIP tokens - TURN credentials live 12 h and are reissued per connection every 10 min - global attachment storage cap (SEPOT_ATTACHMENTS_TOTAL_BYTES), nosniff and attachment disposition on downloads - SEPOT_MAX_CONNS_PER_IP, /v1/ready backed by the database, graceful shutdown, RLIMIT_NOFILE raised to the hard limit, server-info reports source and revision - Dockerfile: rust 1.99, GIT_SHA build arg for the image label and SEPOT_REVISION; Tor image on alpine 3.24
1259 lines
49 KiB
Rust
1259 lines
49 KiB
Rust
use std::sync::Arc;
|
||
use std::time::Instant;
|
||
|
||
use chrono::{DateTime, Duration, Utc};
|
||
use dashmap::DashMap;
|
||
use prost::Message;
|
||
use sepot_proto::v1;
|
||
use sepot_proto::MAX_ENVELOPE_PAYLOAD;
|
||
use sqlx::PgPool;
|
||
use tokio::sync::mpsc;
|
||
|
||
use crate::certs::CertAuthority;
|
||
use crate::net::TrustedProxies;
|
||
|
||
/// TTL offline fronty.
|
||
pub const QUEUE_TTL_DAYS: i64 = 30;
|
||
|
||
/// Strop celkové velikosti fronty na příjemce (SECURITY audit #3). Bez něj
|
||
/// autentizovaný útočník nasype neomezeně obálek (256 KiB × N) a při flushi
|
||
/// z toho vznikne OOM, na disku neomezený růst. Přetečení = FIFO zahození
|
||
/// nejstarších. 50 MiB ≈ 200 max obálek na příjemce.
|
||
const MAX_QUEUE_BYTES_PER_RECIPIENT: i64 = 50 * 1024 * 1024;
|
||
|
||
/// Velikost dávky při vylévání fronty (SECURITY audit #3). Strop paměti =
|
||
/// FLUSH_BATCH × MAX_ENVELOPE_PAYLOAD (~16 MiB), ne celá fronta najednou.
|
||
const FLUSH_BATCH: i64 = 64;
|
||
|
||
/// Po kolika marných pokusech o doručení obálku zahodit.
|
||
///
|
||
/// Ztracený ACK je jednosměrka: klient obálku dešifruje (ratchet postoupí),
|
||
/// uloží a pošle ACK — když ten cestou zmizí (mrtvé spojení), relay obálku
|
||
/// drží dál, jenže druhý pokus o dešifrování už libsignal odmítne. Klient ji
|
||
/// tedy neACKuje a fronta se otravuje: tatáž obálka se posílá při každém
|
||
/// připojení až do TTL 30 dní a příjemce ji nikdy nedostane. Strop tomu dá
|
||
/// konec. Osm pokusů je velkoryse nad běžné „appka spadla před ACK".
|
||
const MAX_DELIVERY_ATTEMPTS: i16 = 8;
|
||
|
||
/// Jak dlouho po posledním rámci od klienta se spojení ještě považuje za
|
||
/// prokazatelně živé. Klient odpovídá na ping à 30 s (ws::PING_INTERVAL),
|
||
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
||
const FRESH_CONNECTION_MS: u64 = 70_000;
|
||
|
||
/// Výchozí strop souběžných WS spojení z jedné IP. Env
|
||
/// `SEPOT_MAX_CONNS_PER_IP` ho přepisuje — za CGNAT mobilních operátorů
|
||
/// sdílí jednu IP stovky lidí.
|
||
const DEFAULT_MAX_CONNS_PER_IP: u32 = 32;
|
||
|
||
/// Běžný budíček na jeden cíl nejvýš jednou za tohle okno: třicet zpráv ve
|
||
/// skupině během výpadku sítě = jeden push, ne třicet (APNs i FCM nápor
|
||
/// tichých pushů škrtí, baterie taky).
|
||
const WAKE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||
/// Budíček hovoru (VoIP push / FCM k=call) zvoní na telefonu hned a bez
|
||
/// ověření — proto má vlastní, přísnější strop na cíl: nejvýš jeden za
|
||
/// CALL_WAKE_MIN_INTERVAL a CALL_WAKE_MAX_PER_WINDOW za CALL_WAKE_WINDOW.
|
||
/// Nad strop se obálka uloží a jde jen běžný budíček (phantom
|
||
/// hovory od kohokoli, kdo zná adresu).
|
||
const CALL_WAKE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
|
||
const CALL_WAKE_WINDOW: std::time::Duration = std::time::Duration::from_secs(600);
|
||
const CALL_WAKE_MAX_PER_WINDOW: usize = 6;
|
||
/// Kolik one-time prekeys smí relay vydat na jednu adresu za hodinu.
|
||
/// Noví kontakti jsou jednotky za den; sto GET /v1/keys za dvacet sekund
|
||
/// je vyčerpání zásoby cizím. Nad strop jde bundle bez OTP.
|
||
const OTP_ISSUE_MAX_PER_HOUR: u32 = 30;
|
||
const OTP_ISSUE_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
|
||
|
||
/// Značky budíčků jednoho cíle (jen v paměti, nikam se neukládají).
|
||
#[derive(Default)]
|
||
pub struct WakeMarks {
|
||
last_wake: Option<Instant>,
|
||
call_wakes: std::collections::VecDeque<Instant>,
|
||
}
|
||
|
||
/// Připojené zařízení: kanál na jeho WS + kdy od něj naposled něco přišlo.
|
||
///
|
||
/// Samotný `tx` o životnosti spojení nic neříká — je to mpsc s bufferem, do
|
||
/// kterého zápis projde i u dávno mrtvého socketu. Právě proto se dřív vracelo
|
||
/// falešné DELIVERED_ONLINE a push budíček se vůbec nezkusil.
|
||
pub struct OnlineDevice {
|
||
pub tx: mpsc::Sender<v1::ServerFrame>,
|
||
/// ms od startu procesu; monotónní, aby s ním neházel NTP.
|
||
last_frame_ms: std::sync::atomic::AtomicU64,
|
||
}
|
||
|
||
impl OnlineDevice {
|
||
pub fn new(tx: mpsc::Sender<v1::ServerFrame>, now_ms: u64) -> Self {
|
||
Self {
|
||
tx,
|
||
last_frame_ms: std::sync::atomic::AtomicU64::new(now_ms),
|
||
}
|
||
}
|
||
|
||
/// Přišel rámec od klienta (Pong/ACK/Envelope) — spojení dýchá.
|
||
pub fn touch(&self, now_ms: u64) {
|
||
self.last_frame_ms
|
||
.store(now_ms, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
|
||
fn is_fresh(&self, now_ms: u64) -> bool {
|
||
let last = self.last_frame_ms.load(std::sync::atomic::Ordering::Relaxed);
|
||
now_ms.saturating_sub(last) <= FRESH_CONNECTION_MS
|
||
}
|
||
}
|
||
|
||
/// Cíl živého doručování: delivery_address → spojení, na kterém zařízení
|
||
/// právě poslouchá.
|
||
///
|
||
/// Jedno zařízení může mít chvíli víc spojení naráz: po změně sítě (Wi-Fi ↔
|
||
/// mobilní data) se appka připojí znovu hned, kdežto staré spojení relay
|
||
/// pozná jako mrtvé až po `ws::IDLE_TIMEOUT`. Doručuje se vždy na naposledy
|
||
/// ověřené spojení a staré spojení smí při svém konci odebrat jen samo sebe.
|
||
#[derive(Default)]
|
||
pub struct OnlineDevices(DashMap<Vec<u8>, Arc<OnlineDevice>>);
|
||
|
||
impl OnlineDevices {
|
||
pub fn get(&self, delivery_address: &[u8]) -> Option<Arc<OnlineDevice>> {
|
||
self.0.get(delivery_address).map(|d| d.clone())
|
||
}
|
||
|
||
/// Nově ověřené spojení přebírá doručování — i od staršího spojení
|
||
/// téhož zařízení, které ještě nevypršelo.
|
||
pub fn attach(&self, delivery_address: Vec<u8>, device: Arc<OnlineDevice>) {
|
||
self.0.insert(delivery_address, device);
|
||
}
|
||
|
||
/// Konec spojení: odebere záznam, jen pokud pořád patří tomuto spojení.
|
||
/// Mazat podle samotné adresy nejde — zombie spojení, které dožije až
|
||
/// po reconnectu, by tím vyřadilo nové živé spojení a relay by pak každou
|
||
/// zprávu jen uložil do fronty a čekal na budíček, přestože je zařízení
|
||
/// připojené. Vrací, zda se záznam odebral.
|
||
pub fn detach(&self, delivery_address: &[u8], device: &Arc<OnlineDevice>) -> bool {
|
||
self.0
|
||
.remove_if(delivery_address, |_, current| Arc::ptr_eq(current, device))
|
||
.is_some()
|
||
}
|
||
}
|
||
|
||
pub struct AppState {
|
||
pub pool: PgPool,
|
||
/// Kdy proces nastartoval — základ monotónních značek v [`OnlineDevice`].
|
||
pub started: std::time::Instant,
|
||
/// delivery_address → živé WS spojení zařízení.
|
||
pub online: OnlineDevices,
|
||
/// klientská IP → počet souběžných WS spojení (anti-flood cap).
|
||
/// Jen v paměti, nikam se neloguje ani neukládá.
|
||
pub ws_conns: DashMap<std::net::IpAddr, u32>,
|
||
pub certs: CertAuthority,
|
||
/// Trusted proxy sada pro určení klientské IP (WS conn-cap). Sdílená
|
||
/// s governor limity — komu se věří forwarded hlavička.
|
||
pub trusted_proxies: Arc<TrustedProxies>,
|
||
/// HTTP klient na push budíčky (bez redirectů, krátký timeout).
|
||
pub http: reqwest::Client,
|
||
/// Úložiště šifrovaných příloh (adresář + strop velikosti).
|
||
pub attachments: crate::attachments::Config,
|
||
/// FCM fast mode (None = nenakonfigurováno, budíčky jen UnifiedPush).
|
||
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>,
|
||
/// Strop WS spojení na IP.
|
||
pub max_conns_per_ip: u32,
|
||
/// delivery_address → značky budíčků (throttling pushů a hovorů).
|
||
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
||
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
||
pub otp_issued: DashMap<Vec<u8>, (Instant, u32)>,
|
||
/// Odkaz na zdroj a revize běžící binárky (AGPL §13, /v1/server-info).
|
||
pub source_url: String,
|
||
pub revision: String,
|
||
}
|
||
|
||
impl AppState {
|
||
pub fn new(
|
||
pool: PgPool,
|
||
certs: CertAuthority,
|
||
attachments: crate::attachments::Config,
|
||
trusted_proxies: Arc<TrustedProxies>,
|
||
) -> Self {
|
||
Self {
|
||
pool,
|
||
started: std::time::Instant::now(),
|
||
online: OnlineDevices::default(),
|
||
ws_conns: DashMap::new(),
|
||
certs,
|
||
trusted_proxies,
|
||
http: crate::push::build_client(),
|
||
attachments,
|
||
fcm: crate::fcm::FcmSender::from_env(),
|
||
apns: crate::apns::ApnsSender::from_env(),
|
||
turn: crate::turn::TurnConfig::from_env(),
|
||
max_conns_per_ip: std::env::var("SEPOT_MAX_CONNS_PER_IP")
|
||
.ok()
|
||
.and_then(|v| v.parse().ok())
|
||
.unwrap_or(DEFAULT_MAX_CONNS_PER_IP),
|
||
wake_marks: DashMap::new(),
|
||
otp_issued: DashMap::new(),
|
||
source_url: std::env::var("SEPOT_SOURCE_URL")
|
||
.unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()),
|
||
revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()),
|
||
}
|
||
}
|
||
|
||
/// Úklid značek starších než největší okno — volá periodická úloha.
|
||
pub fn prune_marks(&self) {
|
||
let now = Instant::now();
|
||
self.wake_marks.retain(|_, m| {
|
||
m.call_wakes
|
||
.retain(|t| now.duration_since(*t) < CALL_WAKE_WINDOW);
|
||
m.last_wake
|
||
.map(|t| now.duration_since(t) < CALL_WAKE_WINDOW)
|
||
.unwrap_or(false)
|
||
|| !m.call_wakes.is_empty()
|
||
});
|
||
self.otp_issued
|
||
.retain(|_, (start, _)| now.duration_since(*start) < OTP_ISSUE_WINDOW);
|
||
}
|
||
|
||
/// Smí se vydat další OTP pro tuhle adresu? Počítá v hodinovém okně.
|
||
pub fn allow_otp_issue(&self, dst_address: &[u8]) -> bool {
|
||
let now = Instant::now();
|
||
let mut entry = self
|
||
.otp_issued
|
||
.entry(dst_address.to_vec())
|
||
.or_insert((now, 0));
|
||
if now.duration_since(entry.0) >= OTP_ISSUE_WINDOW {
|
||
*entry = (now, 0);
|
||
}
|
||
if entry.1 >= OTP_ISSUE_MAX_PER_HOUR {
|
||
return false;
|
||
}
|
||
entry.1 += 1;
|
||
true
|
||
}
|
||
|
||
/// Běžný budíček smí odejít nejvýš jednou za WAKE_MIN_INTERVAL na cíl.
|
||
fn allow_wake(&self, dst_address: &[u8]) -> bool {
|
||
let now = Instant::now();
|
||
let mut marks = self.wake_marks.entry(dst_address.to_vec()).or_default();
|
||
if marks
|
||
.last_wake
|
||
.map(|t| now.duration_since(t) < WAKE_MIN_INTERVAL)
|
||
.unwrap_or(false)
|
||
{
|
||
return false;
|
||
}
|
||
marks.last_wake = Some(now);
|
||
true
|
||
}
|
||
|
||
/// Budíček hovoru: vlastní strop na cíl (viz CALL_WAKE_*). Vrací false,
|
||
/// když je cíl v okně už vyčerpaný — pak jde nanejvýš běžný budíček.
|
||
fn allow_call_wake(&self, dst_address: &[u8]) -> bool {
|
||
let now = Instant::now();
|
||
let mut marks = self.wake_marks.entry(dst_address.to_vec()).or_default();
|
||
marks
|
||
.call_wakes
|
||
.retain(|t| now.duration_since(*t) < CALL_WAKE_WINDOW);
|
||
if let Some(last) = marks.call_wakes.back() {
|
||
if now.duration_since(*last) < CALL_WAKE_MIN_INTERVAL {
|
||
return false;
|
||
}
|
||
}
|
||
if marks.call_wakes.len() >= CALL_WAKE_MAX_PER_WINDOW {
|
||
return false;
|
||
}
|
||
marks.call_wakes.push_back(now);
|
||
marks.last_wake = Some(now);
|
||
true
|
||
}
|
||
|
||
/// Kolik one-time prekeys zařízení ještě má.
|
||
pub async fn one_time_prekey_count(&self, device_uuid: &[u8]) -> anyhow::Result<i64> {
|
||
let (n,): (i64,) =
|
||
sqlx::query_as("SELECT count(*) FROM one_time_prekeys WHERE device_uuid = $1")
|
||
.bind(device_uuid)
|
||
.fetch_one(&self.pool)
|
||
.await?;
|
||
Ok(n)
|
||
}
|
||
|
||
/// Přijme obálku: validace → perzistentní fronta (dedup) → pokud je
|
||
/// příjemce online, doručí hned. At-least-once: záznam z fronty maže
|
||
/// až ACK příjemce, ne odeslání.
|
||
pub async fn route_envelope(
|
||
&self,
|
||
env: v1::Envelope,
|
||
) -> anyhow::Result<v1::receipt::Status> {
|
||
if env.dst_address.len() != 32
|
||
|| env.msg_uuid.len() != 16
|
||
|| env.payload.is_empty()
|
||
|| env.payload.len() > MAX_ENVELOPE_PAYLOAD
|
||
{
|
||
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);
|
||
sqlx::query(
|
||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
||
VALUES ($1, $2, $3, $4) ON CONFLICT (msg_uuid) DO NOTHING",
|
||
)
|
||
.bind(&env.msg_uuid)
|
||
.bind(&dst)
|
||
.bind(&encoded)
|
||
.bind(expires)
|
||
.execute(&self.pool)
|
||
.await?;
|
||
// FIFO strop na příjemce (audit #3). Nově vložená má nejmladší
|
||
// 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)),
|
||
};
|
||
let fresh = dev.is_fresh(self.now_ms());
|
||
// try_send, ne send().await: plný kanál znamená, že příjemce ze
|
||
// socketu nečte. Čekat na něj by drželo odesílatelovo spojení
|
||
// (a jeho paměť) rukojmím — nečtoucí příjemci by tak shodili
|
||
// relay. Obálka je v DB, půjde budíček.
|
||
let queued = dev.tx.try_send(deliver).is_ok();
|
||
// ONLINE hlásíme jen u spojení, které prokazatelně dýchá. Do
|
||
// zatuchlého se sice pošle taky (kdyby to byl jen tichý klient),
|
||
// ale propadne se i na push — jinak by mrtvé spojení budíček
|
||
// spolklo a příjemce se o zprávě nedozvěděl.
|
||
if fresh && queued {
|
||
return Ok(v1::receipt::Status::DeliveredOnline);
|
||
}
|
||
}
|
||
// Offline, zatuchlé nebo plný kanál → zkusit push budíček.
|
||
if is_call && self.allow_call_wake(&dst) {
|
||
self.wake_call(&dst).await;
|
||
} else if self.allow_wake(&dst) {
|
||
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.try_send(deliver).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.
|
||
async fn trim_queue(&self, dst: &[u8], cap_bytes: i64) -> anyhow::Result<u64> {
|
||
let res = sqlx::query(
|
||
"DELETE FROM message_queue
|
||
WHERE dst_address = $1 AND msg_uuid IN (
|
||
SELECT msg_uuid FROM (
|
||
SELECT msg_uuid,
|
||
sum(octet_length(envelope)) OVER (
|
||
ORDER BY enqueued_at DESC, msg_uuid DESC
|
||
ROWS UNBOUNDED PRECEDING) AS running
|
||
FROM message_queue WHERE dst_address = $1
|
||
) s WHERE running > $2
|
||
)",
|
||
)
|
||
.bind(dst)
|
||
.bind(cap_bytes)
|
||
.execute(&self.pool)
|
||
.await?;
|
||
Ok(res.rows_affected())
|
||
}
|
||
|
||
/// Monotónní značka pro [`OnlineDevice`] — ms od startu procesu.
|
||
pub fn now_ms(&self) -> u64 {
|
||
self.started.elapsed().as_millis() as u64
|
||
}
|
||
|
||
/// Budíček pro offline příjemce: dohledá jeho push token a pošle prázdný
|
||
/// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli.
|
||
/// Endpoint se NELOGUJE.
|
||
async fn wake(&self, dst_address: &[u8]) {
|
||
let Some((platform, token)) = self.push_target(dst_address).await else {
|
||
return; // bez tokenu / anonymní režim → žádný push
|
||
};
|
||
let client = self.http.clone();
|
||
let fcm = self.fcm.clone();
|
||
let apns = self.apns.clone();
|
||
tokio::spawn(async move {
|
||
crate::push::send_wake(&client, platform, &token, fcm.as_deref(), apns.as_deref())
|
||
.await;
|
||
});
|
||
}
|
||
|
||
/// 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>)> {
|
||
let row: Option<(i16, Option<Vec<u8>>)> = sqlx::query_as(
|
||
"SELECT p.platform, p.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();
|
||
match row {
|
||
Some((platform, Some(token))) => Some((platform, token)),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Registrace/aktualizace push endpointu zařízení. platform=NONE token
|
||
/// smaže (anonymní režim). Volá se z autentizovaného WS.
|
||
pub async fn register_push(
|
||
&self,
|
||
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")
|
||
.bind(device_uuid)
|
||
.execute(&self.pool)
|
||
.await?;
|
||
return Ok(());
|
||
}
|
||
sqlx::query(
|
||
"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(())
|
||
}
|
||
|
||
/// Deregistrace účtu (GDPR čl. 17). Kaskáda v DB odnese devices →
|
||
/// prekeys + push_tokens; fronta zpráv je vázaná na delivery_address
|
||
/// bez FK, maže se explicitně v téže transakci. Přílohy vazbu na účet
|
||
/// nemají (bearer capability) — dožijí TTL sweep.
|
||
pub async fn delete_identity(&self, account_id: &[u8]) -> anyhow::Result<()> {
|
||
let mut tx = self.pool.begin().await?;
|
||
sqlx::query(
|
||
"DELETE FROM message_queue WHERE dst_address IN (
|
||
SELECT delivery_address FROM devices WHERE account_id = $1)",
|
||
)
|
||
.bind(account_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
sqlx::query("DELETE FROM identities WHERE account_id = $1")
|
||
.bind(account_id)
|
||
.execute(&mut *tx)
|
||
.await?;
|
||
tx.commit().await?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream). Čte po
|
||
/// dávkách keyset kurzorem přes (enqueued_at, msg_uuid) — paměť je
|
||
/// omezená na FLUSH_BATCH obálek, ne celá fronta najednou (audit #3).
|
||
/// Index idx_queue_dst(dst_address, enqueued_at) kurzor pokrývá.
|
||
pub async fn flush_queue(
|
||
&self,
|
||
delivery_address: &[u8],
|
||
tx: &mpsc::Sender<v1::ServerFrame>,
|
||
) -> anyhow::Result<usize> {
|
||
// Obálky, které strop pokusů vyčerpaly, jsou neprůchodné (skoro vždy
|
||
// ztracený ACK po úspěšném dešifrování) — zahodit dřív, než se znovu
|
||
// pošlou. Maže se až tady, ne hned po inkrementu: poslední pokus
|
||
// dostane šanci doběhnout do ACK.
|
||
let dropped = sqlx::query(
|
||
"DELETE FROM message_queue WHERE dst_address = $1 AND attempt_count >= $2",
|
||
)
|
||
.bind(delivery_address)
|
||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||
.execute(&self.pool)
|
||
.await?
|
||
.rows_affected();
|
||
if dropped > 0 {
|
||
// Bez identifikátoru příjemce — jen že se fronta čistila.
|
||
tracing::info!(dropped, "nedoručitelné obálky zahozeny (strop pokusů)");
|
||
}
|
||
|
||
let mut total = 0usize;
|
||
// (enqueued_at, msg_uuid) poslední odeslané obálky = kurzor další dávky.
|
||
let mut cursor: Option<(DateTime<Utc>, Vec<u8>)> = None;
|
||
loop {
|
||
let rows: Vec<(Vec<u8>, DateTime<Utc>, Vec<u8>)> = match &cursor {
|
||
None => {
|
||
sqlx::query_as(
|
||
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
|
||
WHERE dst_address = $1 AND expires_at > now()
|
||
AND attempt_count < $2
|
||
ORDER BY enqueued_at, msg_uuid
|
||
LIMIT $3",
|
||
)
|
||
.bind(delivery_address)
|
||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||
.bind(FLUSH_BATCH)
|
||
.fetch_all(&self.pool)
|
||
.await?
|
||
}
|
||
Some((ts, uuid)) => {
|
||
sqlx::query_as(
|
||
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
|
||
WHERE dst_address = $1 AND expires_at > now()
|
||
AND attempt_count < $2
|
||
AND (enqueued_at, msg_uuid) > ($3, $4)
|
||
ORDER BY enqueued_at, msg_uuid
|
||
LIMIT $5",
|
||
)
|
||
.bind(delivery_address)
|
||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||
.bind(ts)
|
||
.bind(uuid)
|
||
.bind(FLUSH_BATCH)
|
||
.fetch_all(&self.pool)
|
||
.await?
|
||
}
|
||
};
|
||
let batch_len = rows.len();
|
||
if batch_len == 0 {
|
||
break;
|
||
}
|
||
let last = rows
|
||
.last()
|
||
.map(|(_, ts, uuid)| (*ts, uuid.clone()))
|
||
.expect("dávka není prázdná");
|
||
let mut sent_uuids: Vec<Vec<u8>> = Vec::with_capacity(batch_len);
|
||
for (encoded, _, uuid) in &rows {
|
||
let env = v1::Envelope::decode(encoded.as_slice())?;
|
||
let frame = v1::ServerFrame {
|
||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||
};
|
||
if tx.send(frame).await.is_err() {
|
||
// Příjemce odpadl uprostřed dávky — započítat jen to, co
|
||
// reálně odešlo, ať pokus „zdarma" nekrátí strop.
|
||
self.bump_attempts(&sent_uuids).await;
|
||
return Ok(total);
|
||
}
|
||
sent_uuids.push(uuid.clone());
|
||
total += 1;
|
||
}
|
||
self.bump_attempts(&sent_uuids).await;
|
||
if (batch_len as i64) < FLUSH_BATCH {
|
||
break; // poslední (neúplná) dávka
|
||
}
|
||
cursor = Some(last);
|
||
}
|
||
Ok(total)
|
||
}
|
||
|
||
/// Započítá pokus o doručení odeslaným obálkám. Selhání se jen zaloguje —
|
||
/// obálky už jsou na drátě a shodit kvůli tomu flush by bylo horší než
|
||
/// nepřesné počítadlo.
|
||
async fn bump_attempts(&self, msg_uuids: &[Vec<u8>]) {
|
||
if msg_uuids.is_empty() {
|
||
return;
|
||
}
|
||
if let Err(e) = sqlx::query(
|
||
"UPDATE message_queue SET attempt_count = attempt_count + 1
|
||
WHERE msg_uuid = ANY($1)",
|
||
)
|
||
.bind(msg_uuids)
|
||
.execute(&self.pool)
|
||
.await
|
||
{
|
||
tracing::warn!("attempt_count se nepodařilo zvýšit: {e}");
|
||
}
|
||
}
|
||
|
||
/// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
|
||
pub async fn ack(&self, delivery_address: &[u8], msg_uuid: &[u8]) -> anyhow::Result<()> {
|
||
sqlx::query("DELETE FROM message_queue WHERE msg_uuid = $1 AND dst_address = $2")
|
||
.bind(msg_uuid)
|
||
.bind(delivery_address)
|
||
.execute(&self.pool)
|
||
.await?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod online_tests {
|
||
use super::*;
|
||
|
||
fn spojeni() -> Arc<OnlineDevice> {
|
||
let (tx, _rx) = mpsc::channel::<v1::ServerFrame>(1);
|
||
Arc::new(OnlineDevice::new(tx, 0))
|
||
}
|
||
|
||
#[test]
|
||
fn novejsi_spojeni_prebira_dorucovani() {
|
||
let online = OnlineDevices::default();
|
||
let adresa = vec![7u8; 32];
|
||
let (stare, nove) = (spojeni(), spojeni());
|
||
|
||
online.attach(adresa.clone(), stare.clone());
|
||
online.attach(adresa.clone(), nove.clone());
|
||
|
||
let cil = online.get(&adresa).expect("adresa je online");
|
||
assert!(Arc::ptr_eq(&cil, &nove));
|
||
}
|
||
|
||
#[test]
|
||
fn konec_stareho_spojeni_neodebere_nove() {
|
||
let online = OnlineDevices::default();
|
||
let adresa = vec![7u8; 32];
|
||
let (stare, nove) = (spojeni(), spojeni());
|
||
online.attach(adresa.clone(), stare.clone());
|
||
online.attach(adresa.clone(), nove.clone());
|
||
|
||
assert!(!online.detach(&adresa, &stare), "cizí záznam se nesmí odebrat");
|
||
let cil = online.get(&adresa).expect("nové spojení zůstává online");
|
||
assert!(Arc::ptr_eq(&cil, &nove));
|
||
|
||
assert!(online.detach(&adresa, &nove));
|
||
assert!(online.get(&adresa).is_none());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod db_tests {
|
||
//! Integrace push registrace nad reálným Postgresem. #[ignore] — spouštět
|
||
//! s běžící dev DB: `cargo test -p sepot-relay -- --ignored`. Používá
|
||
//! náhodná ID a po sobě uklízí (žádná pollution existujících dat).
|
||
use super::*;
|
||
use rand::RngCore;
|
||
|
||
async fn test_state() -> AppState {
|
||
let url = std::env::var("DATABASE_URL")
|
||
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||
.max_connections(2)
|
||
.connect(&url)
|
||
.await
|
||
.expect("dev DB");
|
||
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
||
let trusted = Arc::new(crate::net::TrustedProxies::from_env());
|
||
AppState::new(pool, certs, crate::attachments::Config::from_env(), trusted)
|
||
}
|
||
|
||
fn rand_bytes(n: usize) -> Vec<u8> {
|
||
let mut v = vec![0u8; n];
|
||
rand::rng().fill_bytes(&mut v);
|
||
v
|
||
}
|
||
|
||
/// Naseje identitu+zařízení. Vrací (account_id, device_uuid, delivery_address).
|
||
async fn seed_device(state: &AppState) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
|
||
let account_id = rand_bytes(16);
|
||
let identity_key = rand_bytes(33); // CHECK octet_length = 33
|
||
let device_uuid = rand_bytes(16);
|
||
let delivery_address = rand_bytes(32);
|
||
sqlx::query("INSERT INTO identities (account_id, identity_key) VALUES ($1, $2)")
|
||
.bind(&account_id)
|
||
.bind(&identity_key)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"INSERT INTO devices
|
||
(device_uuid, account_id, device_id, registration_id, delivery_address)
|
||
VALUES ($1, $2, 1, 1, $3)",
|
||
)
|
||
.bind(&device_uuid)
|
||
.bind(&account_id)
|
||
.bind(&delivery_address)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
(account_id, device_uuid, delivery_address)
|
||
}
|
||
|
||
async fn cleanup(state: &AppState, account_id: &[u8], delivery_address: &[u8]) {
|
||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||
.bind(delivery_address)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
// CASCADE smaže devices i push_tokens
|
||
sqlx::query("DELETE FROM identities WHERE account_id = $1")
|
||
.bind(account_id)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||
async fn register_push_upsert_a_lookup() {
|
||
let state = test_state().await;
|
||
let (account_id, device_uuid, delivery_address) = seed_device(&state).await;
|
||
|
||
// bez registrace → žádný cíl
|
||
assert!(state.push_target(&delivery_address).await.is_none());
|
||
|
||
// UnifiedPush endpoint
|
||
let ep1 = b"https://ntfy.sh/UPabc".to_vec();
|
||
state
|
||
.register_push(&device_uuid, crate::push::PLATFORM_UNIFIEDPUSH, Some(&ep1), None)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(
|
||
state.push_target(&delivery_address).await,
|
||
Some((crate::push::PLATFORM_UNIFIEDPUSH, ep1))
|
||
);
|
||
|
||
// 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), None)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(
|
||
state.push_target(&delivery_address).await,
|
||
Some((crate::push::PLATFORM_WEBPUSH, ep2))
|
||
);
|
||
|
||
// NONE → smaže (anonymní režim)
|
||
state
|
||
.register_push(&device_uuid, crate::push::PLATFORM_NONE, None, None)
|
||
.await
|
||
.unwrap();
|
||
assert!(state.push_target(&delivery_address).await.is_none());
|
||
|
||
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() {
|
||
let state = test_state().await;
|
||
let (account_id, device_uuid, delivery_address) = seed_device(&state).await;
|
||
// Endpoint schválně na loopback → guard ho odmítne, žádný externí POST
|
||
// (test zůstává hermetický). Ověřujeme, že offline cesta projde a
|
||
// route_envelope se na budíčku nezhroutí.
|
||
state
|
||
.register_push(
|
||
&device_uuid,
|
||
crate::push::PLATFORM_UNIFIEDPUSH,
|
||
Some(b"https://localhost/up"),
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let env = v1::Envelope {
|
||
dst_address: delivery_address.clone(),
|
||
msg_uuid: rand_bytes(16),
|
||
content_hint: 0,
|
||
payload: vec![0xAB; 64],
|
||
};
|
||
let status = state.route_envelope(env).await.unwrap();
|
||
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
||
|
||
// obálka je ve frontě (příjemce byl offline)
|
||
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);
|
||
|
||
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() {
|
||
let state = test_state().await;
|
||
let (account_id, device_uuid, delivery_address) = seed_device(&state).await;
|
||
|
||
// účet má push token i nedoručenou zprávu ve frontě
|
||
state
|
||
.register_push(
|
||
&device_uuid,
|
||
crate::push::PLATFORM_UNIFIEDPUSH,
|
||
Some(b"https://localhost/up"),
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
sqlx::query(
|
||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
||
VALUES ($1, $2, $3, now() + interval '30 days')",
|
||
)
|
||
.bind(rand_bytes(16))
|
||
.bind(&delivery_address)
|
||
.bind(vec![0xCDu8; 64])
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
|
||
state.delete_identity(&account_id).await.unwrap();
|
||
|
||
let identities: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM identities WHERE account_id = $1")
|
||
.bind(&account_id)
|
||
.fetch_one(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
let devices: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM devices WHERE account_id = $1")
|
||
.bind(&account_id)
|
||
.fetch_one(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
let queued: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
||
.bind(&delivery_address)
|
||
.fetch_one(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
let tokens: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM push_tokens WHERE device_uuid = $1")
|
||
.bind(&device_uuid)
|
||
.fetch_one(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!((identities, devices, queued, tokens), (0, 0, 0, 0));
|
||
|
||
// idempotence: druhé smazání neexistujícího účtu nesmí spadnout
|
||
state.delete_identity(&account_id).await.unwrap();
|
||
}
|
||
|
||
/// Audit #3: flush přes hranici dávky (150 > FLUSH_BATCH) nesmí keyset
|
||
/// kurzorem žádnou obálku ztratit ani zdvojit.
|
||
#[tokio::test]
|
||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||
async fn flush_queue_davkuje_pres_hranici_bez_ztraty() {
|
||
let state = test_state().await;
|
||
let dst = rand_bytes(32);
|
||
let n = 150usize;
|
||
let mut expected = std::collections::HashSet::new();
|
||
for i in 0..n {
|
||
let msg_uuid = rand_bytes(16);
|
||
expected.insert(msg_uuid.clone());
|
||
let env = v1::Envelope {
|
||
dst_address: dst.clone(),
|
||
msg_uuid: msg_uuid.clone(),
|
||
content_hint: 0,
|
||
payload: vec![0x11; 32],
|
||
};
|
||
sqlx::query(
|
||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
|
||
VALUES ($1, $2, $3, now() + (interval '1 millisecond' * $4), now() + interval '30 days')",
|
||
)
|
||
.bind(&msg_uuid)
|
||
.bind(&dst)
|
||
.bind(env.encode_to_vec())
|
||
.bind(i as i32)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(256);
|
||
let count = state.flush_queue(&dst, &tx).await.unwrap();
|
||
drop(tx);
|
||
assert_eq!(count, n);
|
||
|
||
let mut got = std::collections::HashSet::new();
|
||
while let Some(frame) = rx.recv().await {
|
||
if let Some(v1::server_frame::Frame::Deliver(env)) = frame.frame {
|
||
assert!(got.insert(env.msg_uuid), "obálka doručena dvakrát");
|
||
}
|
||
}
|
||
assert_eq!(got, expected, "sada doručených ≠ sada zařazených");
|
||
|
||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||
.bind(&dst)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
/// Zapsat do mpsc bufferu jde i u dávno mrtvého spojení — DELIVERED_ONLINE
|
||
/// se proto smí hlásit jen u zařízení, od kterého nedávno něco přišlo.
|
||
/// Zatuchlé musí spadnout na offline větev, jinak spolkne push budíček.
|
||
#[tokio::test]
|
||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||
async fn route_envelope_zatuchle_spojeni_neni_online() {
|
||
let mut state = test_state().await;
|
||
// Posunout start procesu do minulosti, ať jde značka „naposled se
|
||
// ozval na začátku" vyjádřit bez čekání reálných 70 s.
|
||
state.started = std::time::Instant::now()
|
||
- std::time::Duration::from_millis(FRESH_CONNECTION_MS + 10_000);
|
||
let (_account_id, _device_uuid, delivery_address) = seed_device(&state).await;
|
||
|
||
// Kanál nikdo nečte, ale buffer zápis spolkne — přesně situace
|
||
// mrtvého klienta, kterou dřív relay hlásil jako doručeno.
|
||
let (tx, _rx) = mpsc::channel::<v1::ServerFrame>(64);
|
||
let device = Arc::new(OnlineDevice::new(tx, 0));
|
||
state
|
||
.online
|
||
.attach(delivery_address.clone(), device.clone());
|
||
|
||
let env = v1::Envelope {
|
||
dst_address: delivery_address.clone(),
|
||
msg_uuid: rand_bytes(16),
|
||
content_hint: 0,
|
||
payload: vec![0xCD; 64],
|
||
};
|
||
assert_eq!(
|
||
state.route_envelope(env).await.unwrap(),
|
||
v1::receipt::Status::StoredOffline,
|
||
"zatuchlé spojení se nesmí hlásit jako doručené online"
|
||
);
|
||
|
||
// A naopak: čerstvě označené zařízení online být má.
|
||
device.touch(state.now_ms());
|
||
let env2 = v1::Envelope {
|
||
dst_address: delivery_address.clone(),
|
||
msg_uuid: rand_bytes(16),
|
||
content_hint: 0,
|
||
payload: vec![0xCE; 64],
|
||
};
|
||
assert_eq!(
|
||
state.route_envelope(env2).await.unwrap(),
|
||
v1::receipt::Status::DeliveredOnline,
|
||
"živé spojení má být online"
|
||
);
|
||
|
||
state.online.detach(&delivery_address, &device);
|
||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||
.bind(&delivery_address)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
/// Reconnect po změně sítě: staré spojení dožije až po novém a jeho
|
||
/// konec nesmí vyřadit nové spojení z živého doručování.
|
||
#[tokio::test]
|
||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||
async fn route_envelope_po_konci_zombie_spojeni_dorucuje_na_nove() {
|
||
let state = test_state().await;
|
||
let (account_id, _device_uuid, delivery_address) = seed_device(&state).await;
|
||
|
||
let (tx_stare, _rx_stare) = mpsc::channel::<v1::ServerFrame>(64);
|
||
let stare = Arc::new(OnlineDevice::new(tx_stare, state.now_ms()));
|
||
state.online.attach(delivery_address.clone(), stare.clone());
|
||
|
||
let (tx_nove, mut rx_nove) = mpsc::channel::<v1::ServerFrame>(64);
|
||
let nove = Arc::new(OnlineDevice::new(tx_nove, state.now_ms()));
|
||
state.online.attach(delivery_address.clone(), nove.clone());
|
||
|
||
// Handler starého spojení končí (idle timeout) až po reconnectu.
|
||
assert!(!state.online.detach(&delivery_address, &stare));
|
||
|
||
let msg_uuid = rand_bytes(16);
|
||
let env = v1::Envelope {
|
||
dst_address: delivery_address.clone(),
|
||
msg_uuid: msg_uuid.clone(),
|
||
content_hint: 0,
|
||
payload: vec![0xEF; 64],
|
||
};
|
||
assert_eq!(
|
||
state.route_envelope(env).await.unwrap(),
|
||
v1::receipt::Status::DeliveredOnline,
|
||
"konec zombie spojení nesmí vyřadit nové spojení"
|
||
);
|
||
match rx_nove.try_recv().map(|f| f.frame) {
|
||
Ok(Some(v1::server_frame::Frame::Deliver(env))) => assert_eq!(env.msg_uuid, msg_uuid),
|
||
jinak => panic!("nové spojení nedostalo DELIVER: {jinak:?}"),
|
||
}
|
||
|
||
assert!(state.online.detach(&delivery_address, &nove));
|
||
cleanup(&state, &account_id, &delivery_address).await;
|
||
}
|
||
|
||
/// Flush počítá pokusy — bez toho by strop nikdy nezabral.
|
||
#[tokio::test]
|
||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||
async fn flush_queue_pocita_pokusy() {
|
||
let state = test_state().await;
|
||
let dst = rand_bytes(32);
|
||
let msg_uuid = rand_bytes(16);
|
||
let env = v1::Envelope {
|
||
dst_address: dst.clone(),
|
||
msg_uuid: msg_uuid.clone(),
|
||
content_hint: 0,
|
||
payload: vec![0x22; 32],
|
||
};
|
||
sqlx::query(
|
||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
||
VALUES ($1, $2, $3, now() + interval '30 days')",
|
||
)
|
||
.bind(&msg_uuid)
|
||
.bind(&dst)
|
||
.bind(env.encode_to_vec())
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Dva flushe bez ACK = dva započtené pokusy.
|
||
for _ in 0..2 {
|
||
let (tx, rx) = mpsc::channel::<v1::ServerFrame>(16);
|
||
assert_eq!(state.flush_queue(&dst, &tx).await.unwrap(), 1);
|
||
drop(rx);
|
||
}
|
||
|
||
let attempts: (i16,) =
|
||
sqlx::query_as("SELECT attempt_count FROM message_queue WHERE msg_uuid = $1")
|
||
.bind(&msg_uuid)
|
||
.fetch_one(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(attempts.0, 2, "každý flush musí započítat pokus");
|
||
|
||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||
.bind(&dst)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
/// Obálka, kterou příjemce nikdy neACKne (ztracený ACK → ratchet už
|
||
/// postoupil), musí po stropu zmizet, ne otravovat frontu 30 dní.
|
||
#[tokio::test]
|
||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||
async fn flush_queue_zahodi_nepruchodnou_obalku() {
|
||
let state = test_state().await;
|
||
let dst = rand_bytes(32);
|
||
let msg_uuid = rand_bytes(16);
|
||
let env = v1::Envelope {
|
||
dst_address: dst.clone(),
|
||
msg_uuid: msg_uuid.clone(),
|
||
content_hint: 0,
|
||
payload: vec![0x33; 32],
|
||
};
|
||
// Rovnou na stropu = obálka, která se marně posílala MAX_DELIVERY_ATTEMPTS×.
|
||
sqlx::query(
|
||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at, attempt_count)
|
||
VALUES ($1, $2, $3, now() + interval '30 days', $4)",
|
||
)
|
||
.bind(&msg_uuid)
|
||
.bind(&dst)
|
||
.bind(env.encode_to_vec())
|
||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
|
||
let (tx, rx) = mpsc::channel::<v1::ServerFrame>(16);
|
||
assert_eq!(
|
||
state.flush_queue(&dst, &tx).await.unwrap(),
|
||
0,
|
||
"vyčerpaná obálka se nesmí znovu poslat"
|
||
);
|
||
drop(rx);
|
||
|
||
let zbylo: (i64,) =
|
||
sqlx::query_as("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
||
.bind(&dst)
|
||
.fetch_one(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(zbylo.0, 0, "vyčerpaná obálka měla být zahozena");
|
||
}
|
||
|
||
/// Audit #3: FIFO strop zahodí nejstarší přes limit, nejnovější nechá.
|
||
#[tokio::test]
|
||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||
async fn trim_queue_fifo_zahodi_nejstarsi() {
|
||
let state = test_state().await;
|
||
let dst = rand_bytes(32);
|
||
// 5 obálek po 100 B, rostoucí enqueued_at. Strop 250 B → nejnovější
|
||
// dvě (2×100 ≤ 250) zůstanou, tři nejstarší padnou.
|
||
let mut uuids = Vec::new();
|
||
for i in 0..5 {
|
||
let msg_uuid = rand_bytes(16);
|
||
uuids.push(msg_uuid.clone());
|
||
sqlx::query(
|
||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
|
||
VALUES ($1, $2, $3, now() + (interval '1 second' * $4), now() + interval '30 days')",
|
||
)
|
||
.bind(&msg_uuid)
|
||
.bind(&dst)
|
||
.bind(vec![0u8; 100])
|
||
.bind(i as i32)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
|
||
let dropped = state.trim_queue(&dst, 250).await.unwrap();
|
||
assert_eq!(dropped, 3);
|
||
|
||
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
||
"SELECT msg_uuid FROM message_queue WHERE dst_address = $1 ORDER BY enqueued_at",
|
||
)
|
||
.bind(&dst)
|
||
.fetch_all(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(remaining, vec![uuids[3].clone(), uuids[4].clone()]);
|
||
|
||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||
.bind(&dst)
|
||
.execute(&state.pool)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
}
|