fix(relay): keep-alive on WebSocket, cap delivery attempts, honest receipts
Messages arrived late or not at all, for two independent reasons. Dead connections: neither side sent a ping, and after AUTH the server waited for a frame without a timeout. Mobile NAT drops idle TCP silently, so the client wrote into a void while the relay still held the device in its online map — and therefore never sent a push wake-up either. The ping is a protocol-level one rather than v1::Ping, because dart:io answers those on its own; the fix works with clients already in the wild. Poisoned queue: a client decrypts an envelope (ratchet advances), stores it and acknowledges it. If that ACK is lost, a second decryption attempt can only fail, so the envelope is never acknowledged — and was redelivered on every connect until its 30-day TTL, without ever reaching the recipient. attempt_count existed from the first migration but was never counted; it now bounds redelivery. Receipts reported DELIVERED_ONLINE whenever a write into the bounded mpsc channel succeeded, which it does even for a long-dead socket. Online is now claimed only for devices that recently sent something. Push send failures were logged at debug while the relay runs at info, so a rejected wake-up looked exactly like a working one.
This commit is contained in:
@@ -123,11 +123,13 @@ impl ApnsSender {
|
|||||||
.body(r#"{"aps":{"content-available":1}}"#)
|
.body(r#"{"aps":{"content-available":1}}"#)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
// warn, ne debug — viz shodný komentář ve fcm.rs: na RUST_LOG=info
|
||||||
|
// bylo selhání pushe neviditelné.
|
||||||
match resp {
|
match resp {
|
||||||
Ok(r) if !r.status().is_success() => {
|
Ok(r) if !r.status().is_success() => {
|
||||||
tracing::debug!(status = %r.status(), "APNs budíček odmítnut");
|
tracing::warn!(status = %r.status(), "APNs budíček odmítnut");
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!("APNs budíček selhal: {e}"),
|
Err(e) => tracing::warn!("APNs budíček selhal: {e}"),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,11 +143,15 @@ impl FcmSender {
|
|||||||
"android": { "priority": "HIGH" }
|
"android": { "priority": "HIGH" }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 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
|
||||||
|
// rozbitou konfiguraci projektu, obojí je provozní problém.
|
||||||
match http.post(&url).bearer_auth(access).json(&body).send().await {
|
match http.post(&url).bearer_auth(access).json(&body).send().await {
|
||||||
Ok(resp) if !resp.status().is_success() => {
|
Ok(resp) if !resp.status().is_success() => {
|
||||||
tracing::debug!(status = %resp.status(), "FCM budíček odmítnut");
|
tracing::warn!(status = %resp.status(), "FCM budíček odmítnut");
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!("FCM budíček selhal: {e}"),
|
Err(e) => tracing::warn!("FCM budíček selhal: {e}"),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,13 +85,23 @@ pub async fn send_wake(
|
|||||||
/// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli
|
/// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli
|
||||||
/// testovatelnosti proti loopback mocku (guard by loopback jinak odmítl).
|
/// testovatelnosti proti loopback mocku (guard by loopback jinak odmítl).
|
||||||
async fn post_wake(client: &Client, url: &str) {
|
async fn post_wake(client: &Client, url: &str) {
|
||||||
let _ = client
|
let resp = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.header("content-type", "application/json")
|
.header("content-type", "application/json")
|
||||||
.header("ttl", "2419200") // WebPush TTL (28 dní), ntfy ignoruje
|
.header("ttl", "2419200") // WebPush TTL (28 dní), ntfy ignoruje
|
||||||
.body(WAKE_BODY)
|
.body(WAKE_BODY)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
// Výsledek se dřív zahazoval (`let _ =`), takže nešlo poznat, jestli
|
||||||
|
// budíčky vůbec odcházejí. Logujeme jen stavový kód — endpoint je
|
||||||
|
// identifikátor uživatele a do logu nesmí.
|
||||||
|
match resp {
|
||||||
|
Ok(r) if !r.status().is_success() => {
|
||||||
|
tracing::warn!(status = %r.status(), "UnifiedPush budíček odmítnut");
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("UnifiedPush budíček selhal: {e}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Povolit jen bezpečné cíle. https vždy; http jen pro .onion (samo šifruje).
|
/// Povolit jen bezpečné cíle. https vždy; http jen pro .onion (samo šifruje).
|
||||||
@@ -191,7 +201,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verejne_adresy_projdou() {
|
fn verejne_adresy_projdou() {
|
||||||
for ip in ["1.1.1.1", "8.8.8.8", "203.0.113.10", "2606:4700:4700::1111"] {
|
for ip in ["1.1.1.1", "8.8.8.8", "9.9.9.9", "2606:4700:4700::1111"] {
|
||||||
assert!(!is_internal_ip(&ip.parse().unwrap()), "{ip} nemělo být interní");
|
assert!(!is_internal_ip(&ip.parse().unwrap()), "{ip} nemělo být interní");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+457
-22
@@ -1,4 +1,6 @@
|
|||||||
use chrono::{Duration, Utc};
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use prost::Message;
|
use prost::Message;
|
||||||
use sepot_proto::v1;
|
use sepot_proto::v1;
|
||||||
@@ -7,18 +9,80 @@ use sqlx::PgPool;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::certs::CertAuthority;
|
use crate::certs::CertAuthority;
|
||||||
|
use crate::net::TrustedProxies;
|
||||||
|
|
||||||
/// TTL offline fronty.
|
/// TTL offline fronty.
|
||||||
pub const QUEUE_TTL_DAYS: i64 = 30;
|
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;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
/// delivery_address → kanál na živé WS spojení zařízení.
|
/// Kdy proces nastartoval — základ monotónních značek v [`OnlineDevice`].
|
||||||
pub online: DashMap<Vec<u8>, mpsc::Sender<v1::ServerFrame>>,
|
pub started: std::time::Instant,
|
||||||
|
/// delivery_address → živé WS spojení zařízení.
|
||||||
|
pub online: DashMap<Vec<u8>, Arc<OnlineDevice>>,
|
||||||
/// klientská IP → počet souběžných WS spojení (anti-flood cap).
|
/// klientská IP → počet souběžných WS spojení (anti-flood cap).
|
||||||
/// Jen v paměti, nikam se neloguje ani neukládá.
|
/// Jen v paměti, nikam se neloguje ani neukládá.
|
||||||
pub ws_conns: DashMap<std::net::IpAddr, u32>,
|
pub ws_conns: DashMap<std::net::IpAddr, u32>,
|
||||||
pub certs: CertAuthority,
|
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).
|
/// HTTP klient na push budíčky (bez redirectů, krátký timeout).
|
||||||
pub http: reqwest::Client,
|
pub http: reqwest::Client,
|
||||||
/// Úložiště šifrovaných příloh (adresář + strop velikosti).
|
/// Úložiště šifrovaných příloh (adresář + strop velikosti).
|
||||||
@@ -30,12 +94,19 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(pool: PgPool, certs: CertAuthority, attachments: crate::attachments::Config) -> Self {
|
pub fn new(
|
||||||
|
pool: PgPool,
|
||||||
|
certs: CertAuthority,
|
||||||
|
attachments: crate::attachments::Config,
|
||||||
|
trusted_proxies: Arc<TrustedProxies>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
|
started: std::time::Instant::now(),
|
||||||
online: DashMap::new(),
|
online: DashMap::new(),
|
||||||
ws_conns: DashMap::new(),
|
ws_conns: DashMap::new(),
|
||||||
certs,
|
certs,
|
||||||
|
trusted_proxies,
|
||||||
http: crate::push::build_client(),
|
http: crate::push::build_client(),
|
||||||
attachments,
|
attachments,
|
||||||
fcm: crate::fcm::FcmSender::from_env(),
|
fcm: crate::fcm::FcmSender::from_env(),
|
||||||
@@ -71,20 +142,57 @@ impl AppState {
|
|||||||
.bind(expires)
|
.bind(expires)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.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?;
|
||||||
|
|
||||||
if let Some(tx) = self.online.get(&dst) {
|
if let Some(dev) = self.online.get(&dst).map(|d| d.clone()) {
|
||||||
let deliver = v1::ServerFrame {
|
let deliver = v1::ServerFrame {
|
||||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||||
};
|
};
|
||||||
if tx.send(deliver).await.is_ok() {
|
let fresh = dev.is_fresh(self.now_ms());
|
||||||
|
let queued = dev.tx.send(deliver).await.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);
|
return Ok(v1::receipt::Status::DeliveredOnline);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Offline (nebo online kanál selhal) → zkusit push budíček.
|
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
|
||||||
self.wake(&dst).await;
|
self.wake(&dst).await;
|
||||||
Ok(v1::receipt::Status::StoredOffline)
|
Ok(v1::receipt::Status::StoredOffline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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ý
|
/// Budíček pro offline příjemce: dohledá jeho push token a pošle prázdný
|
||||||
/// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli.
|
/// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli.
|
||||||
/// Endpoint se NELOGUJE.
|
/// Endpoint se NELOGUJE.
|
||||||
@@ -172,32 +280,118 @@ impl AppState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream).
|
/// 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(
|
pub async fn flush_queue(
|
||||||
&self,
|
&self,
|
||||||
delivery_address: &[u8],
|
delivery_address: &[u8],
|
||||||
tx: &mpsc::Sender<v1::ServerFrame>,
|
tx: &mpsc::Sender<v1::ServerFrame>,
|
||||||
) -> anyhow::Result<usize> {
|
) -> anyhow::Result<usize> {
|
||||||
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
|
// Obálky, které strop pokusů vyčerpaly, jsou neprůchodné (skoro vždy
|
||||||
"SELECT envelope FROM message_queue
|
// ztracený ACK po úspěšném dešifrování) — zahodit dřív, než se znovu
|
||||||
WHERE dst_address = $1 AND expires_at > now()
|
// pošlou. Maže se až tady, ne hned po inkrementu: poslední pokus
|
||||||
ORDER BY enqueued_at",
|
// 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(delivery_address)
|
||||||
.fetch_all(&self.pool)
|
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||||
.await?;
|
.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 count = rows.len();
|
let mut total = 0usize;
|
||||||
for (encoded,) in rows {
|
// (enqueued_at, msg_uuid) poslední odeslané obálky = kurzor další dávky.
|
||||||
let env = v1::Envelope::decode(encoded.as_slice())?;
|
let mut cursor: Option<(DateTime<Utc>, Vec<u8>)> = None;
|
||||||
let frame = v1::ServerFrame {
|
loop {
|
||||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
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?
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if tx.send(frame).await.is_err() {
|
let batch_len = rows.len();
|
||||||
|
if batch_len == 0 {
|
||||||
break;
|
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}");
|
||||||
}
|
}
|
||||||
Ok(count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
|
/// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
|
||||||
@@ -228,7 +422,8 @@ mod db_tests {
|
|||||||
.await
|
.await
|
||||||
.expect("dev DB");
|
.expect("dev DB");
|
||||||
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
||||||
AppState::new(pool, certs, crate::attachments::Config::from_env())
|
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> {
|
fn rand_bytes(n: usize) -> Vec<u8> {
|
||||||
@@ -413,4 +608,244 @@ mod db_tests {
|
|||||||
// idempotence: druhé smazání neexistujícího účtu nesmí spadnout
|
// idempotence: druhé smazání neexistujícího účtu nesmí spadnout
|
||||||
state.delete_identity(&account_id).await.unwrap();
|
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
|
||||||
|
.insert(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.remove(&delivery_address);
|
||||||
|
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||||
|
.bind(&delivery_address)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-33
@@ -12,6 +12,7 @@ use std::net::{IpAddr, SocketAddr};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::body::Bytes;
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{ConnectInfo, State};
|
use axum::extract::{ConnectInfo, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
@@ -26,38 +27,26 @@ use tokio::time::Instant;
|
|||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Max souběžných WS spojení z jedné IP (za traefikem = reálná klientská
|
/// Max souběžných WS spojení z jedné IP. Klientská IP jde přes trusted-proxy
|
||||||
/// IP z X-Real-Ip; tor kontejner sdílí jednu — proto ne moc těsně).
|
/// logiku (viz net.rs): za traefikem reálná IP z forwarded hlavičky, onion
|
||||||
|
/// klienti sdílí IP tor kontejneru (jeden bucket) — proto ne moc těsně.
|
||||||
const MAX_CONNS_PER_IP: u32 = 32;
|
const MAX_CONNS_PER_IP: u32 = 32;
|
||||||
/// Neautentizované spojení zavřít po tomto čase.
|
/// Neautentizované spojení zavřít po tomto čase.
|
||||||
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
||||||
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
|
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
|
||||||
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
||||||
const FRAME_WINDOW_MAX: u32 = 200;
|
const FRAME_WINDOW_MAX: u32 = 200;
|
||||||
|
/// Jak často server posílá WS Ping autentizovanému klientovi.
|
||||||
/// Klientská IP: hlavičky od trusted proxy (na relay dosáhne jen traefik
|
|
||||||
/// z docker sítě), fallback peer adresa (dev/tor).
|
|
||||||
///
|
///
|
||||||
/// TRUST MODEL — POZOR: X-Real-Ip / X-Forwarded-For se přebírají bezpodmínečně
|
/// Mobilní NAT zahazuje nečinné TCP tiše — bez keepalive se ani jedna strana
|
||||||
/// a řídí per-IP limity (cap spojení, rate limit). To je bezpečné VÝHRADNĚ
|
/// nedozví, že spojení je mrtvé: klient hlásí „připojeno" a píše do prázdna,
|
||||||
/// proto, že před relayem stojí důvěryhodná reverzní proxy (traefik), která
|
/// relay drží záznam v `online` a proto ani nepošle push budíček. Ping je
|
||||||
/// tyhle hlavičky vždy přepisuje reálnou klientskou IP. Klientem poslané
|
/// PROTOKOLOVÝ (ne `v1::Ping`), protože na ten dart:io odpovídá Pongem sám —
|
||||||
/// hodnoty tak nikdy neprojdou. Kdyby byl relay vystaven přímo do internetu
|
/// funguje tedy i s klienty, které jsou už venku a aplikační Ping neznají.
|
||||||
/// (bez proxy, která hlavičky sanitizuje), útočník by je mohl libovolně
|
const PING_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
/// podvrhnout, tím rozprostřít provoz přes falešné IP a všechny per-IP limity
|
/// Bez jediného rámce od klienta (stačí Pong) je spojení mrtvé. Tři zmeškané
|
||||||
/// obejít. Relay proto NIKDY nenasazovat bez takové proxy.
|
/// pingy — velkoryse, ať krátký výpadek mobilní sítě spojení nezabije.
|
||||||
fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(95);
|
||||||
let from_header = |name: &str| {
|
|
||||||
headers
|
|
||||||
.get(name)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|v| v.split(',').next())
|
|
||||||
.and_then(|v| v.trim().parse::<IpAddr>().ok())
|
|
||||||
};
|
|
||||||
from_header("x-real-ip")
|
|
||||||
.or_else(|| from_header("x-forwarded-for"))
|
|
||||||
.unwrap_or_else(|| peer.ip())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop-guard: úbytek čítače spojení i při panice/cancelu handleru.
|
/// Drop-guard: úbytek čítače spojení i při panice/cancelu handleru.
|
||||||
struct ConnGuard {
|
struct ConnGuard {
|
||||||
@@ -80,7 +69,7 @@ pub async fn ws_handler(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ip = client_ip(&headers, peer);
|
let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
|
||||||
|
|
||||||
let over_cap = {
|
let over_cap = {
|
||||||
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
||||||
@@ -134,11 +123,28 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
let (mut sink, mut stream) = socket.split();
|
let (mut sink, mut stream) = socket.split();
|
||||||
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(64);
|
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(64);
|
||||||
|
|
||||||
|
// Writer drží sink sám, takže si i pinguje — jinak by control rámce
|
||||||
|
// musely téct přes `tx`, který nese jen aplikační ServerFrame.
|
||||||
let writer = tokio::spawn(async move {
|
let writer = tokio::spawn(async move {
|
||||||
while let Some(frame) = rx.recv().await {
|
let mut ping = tokio::time::interval(PING_INTERVAL);
|
||||||
let bytes = frame.encode_to_vec();
|
// Zpožděné ticky se nedohánějí — po pauze chceme jeden ping, ne dávku.
|
||||||
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
break;
|
ping.tick().await; // první tick padne hned, ten nechceme
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
frame = rx.recv() => {
|
||||||
|
let Some(frame) = frame else { break };
|
||||||
|
let bytes = frame.encode_to_vec();
|
||||||
|
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = ping.tick() => {
|
||||||
|
if sink.send(WsMessage::Ping(Bytes::new())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -147,6 +153,9 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
let mut authed_address: Option<Vec<u8>> = None;
|
let mut authed_address: Option<Vec<u8>> = None;
|
||||||
// device_uuid ověřeného zařízení — klíč pro push registraci.
|
// device_uuid ověřeného zařízení — klíč pro push registraci.
|
||||||
let mut authed_device: Option<Vec<u8>> = None;
|
let mut authed_device: Option<Vec<u8>> = None;
|
||||||
|
// Záznam v `online` mapě — značkuje se každým rámcem od klienta, aby
|
||||||
|
// routing poznal živé spojení od zatuchlého.
|
||||||
|
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
||||||
|
|
||||||
// Anti-abuse okno: počítá VŠECHNY rámce od klienta (envelope/ack/pong)
|
// Anti-abuse okno: počítá VŠECHNY rámce od klienta (envelope/ack/pong)
|
||||||
let mut window_start = Instant::now();
|
let mut window_start = Instant::now();
|
||||||
@@ -163,10 +172,29 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
stream.next().await
|
// Po AUTH hlídá spojení idle timeout: klient, který dýchá, pošle
|
||||||
|
// aspoň Pong na náš ping. Ticho = mrtvé spojení → zavřít, ať
|
||||||
|
// záznam zmizí z `online` a další zpráva jde přes push budíček.
|
||||||
|
// Bez tohohle tam zombie visel, dokud traefiku nedošel readTimeout
|
||||||
|
// (30 min), a push se celou tu dobu vůbec nezkusil.
|
||||||
|
match tokio::time::timeout(IDLE_TIMEOUT, stream.next()).await {
|
||||||
|
Ok(next) => next,
|
||||||
|
Err(_) => {
|
||||||
|
// info, ne debug: relay běží na RUST_LOG=info a kolik
|
||||||
|
// spojení umírá na ticho je provozní signál (kvalita sítě
|
||||||
|
// klientů), ne ladicí detail. Bez identifikátoru zařízení.
|
||||||
|
tracing::info!("ws zavřeno: klient neodpovídá na ping");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let Some(Ok(msg)) = next else { break };
|
let Some(Ok(msg)) = next else { break };
|
||||||
|
|
||||||
|
// Cokoliv od klienta (i Pong na náš ping) = spojení dýchá.
|
||||||
|
if let Some(dev) = &online_device {
|
||||||
|
dev.touch(state.now_ms());
|
||||||
|
}
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
if now.duration_since(window_start) > FRAME_WINDOW {
|
if now.duration_since(window_start) > FRAME_WINDOW {
|
||||||
window_start = now;
|
window_start = now;
|
||||||
@@ -242,9 +270,14 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let device = Arc::new(crate::state::OnlineDevice::new(
|
||||||
|
tx.clone(),
|
||||||
|
state.now_ms(),
|
||||||
|
));
|
||||||
state
|
state
|
||||||
.online
|
.online
|
||||||
.insert(pd.delivery_address.clone(), tx.clone());
|
.insert(pd.delivery_address.clone(), device.clone());
|
||||||
|
online_device = Some(device);
|
||||||
authed_address = Some(pd.delivery_address.clone());
|
authed_address = Some(pd.delivery_address.clone());
|
||||||
|
|
||||||
// Potvrzení AUTH — klient až teď smí hlásit „připojeno"
|
// Potvrzení AUTH — klient až teď smí hlásit „připojeno"
|
||||||
@@ -254,7 +287,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
)))
|
)))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// last_seen jen v hrubé granularitě (privacy — zaokrouhleno na hodinu).
|
// last_seen jen v hrubé granularitě (privacy, §1.2).
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE devices SET last_seen_at = date_trunc('hour', now())
|
"UPDATE devices SET last_seen_at = date_trunc('hour', now())
|
||||||
WHERE device_uuid = $1",
|
WHERE device_uuid = $1",
|
||||||
|
|||||||
Reference in New Issue
Block a user