fix(relay): message loss on large queues, call wake-up throttling, identity reclaim, prekey upload
- 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
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use dashmap::DashMap;
|
||||
@@ -39,6 +40,36 @@ const MAX_DELIVERY_ATTEMPTS: i16 = 8;
|
||||
/// 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
|
||||
@@ -126,6 +157,15 @@ pub struct AppState {
|
||||
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 {
|
||||
@@ -147,9 +187,96 @@ impl AppState {
|
||||
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í.
|
||||
@@ -192,7 +319,11 @@ impl AppState {
|
||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||
};
|
||||
let fresh = dev.is_fresh(self.now_ms());
|
||||
let queued = dev.tx.send(deliver).await.is_ok();
|
||||
// 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
|
||||
@@ -201,10 +332,10 @@ impl AppState {
|
||||
return Ok(v1::receipt::Status::DeliveredOnline);
|
||||
}
|
||||
}
|
||||
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
|
||||
if is_call {
|
||||
// Offline, zatuchlé nebo plný kanál → zkusit push budíček.
|
||||
if is_call && self.allow_call_wake(&dst) {
|
||||
self.wake_call(&dst).await;
|
||||
} else {
|
||||
} else if self.allow_wake(&dst) {
|
||||
self.wake(&dst).await;
|
||||
}
|
||||
Ok(v1::receipt::Status::StoredOffline)
|
||||
@@ -223,7 +354,7 @@ impl AppState {
|
||||
let deliver = v1::ServerFrame {
|
||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||
};
|
||||
if dev.tx.send(deliver).await.is_ok() {
|
||||
if dev.tx.try_send(deliver).is_ok() {
|
||||
v1::receipt::Status::DeliveredOnline
|
||||
} else {
|
||||
v1::receipt::Status::Dropped
|
||||
|
||||
Reference in New Issue
Block a user