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:
Julius Joska
2026-08-13 12:23:17 +02:00
parent 4f2cbdbdc4
commit b82f50f888
5 changed files with 545 additions and 61 deletions
+12 -2
View File
@@ -85,13 +85,23 @@ pub async fn send_wake(
/// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli
/// testovatelnosti proti loopback mocku (guard by loopback jinak odmítl).
async fn post_wake(client: &Client, url: &str) {
let _ = client
let resp = client
.post(url)
.header("content-type", "application/json")
.header("ttl", "2419200") // WebPush TTL (28 dní), ntfy ignoruje
.body(WAKE_BODY)
.send()
.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).
@@ -191,7 +201,7 @@ mod tests {
#[test]
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í");
}
}