udp.rs
| 1 | //! The UDP receive loop. |
| 2 | //! |
| 3 | //! `SO_REUSEPORT` lets several tasks bind the *same* port and have the kernel |
| 4 | //! spread datagrams across them, which is how one port scales past one core |
| 5 | //! without a dispatcher task in the middle. Each worker owns its own socket, so |
| 6 | //! there is no shared state on the hot path at all. |
| 7 | //! |
| 8 | //! The loop deliberately contains no `.await` between receiving and replying: the |
| 9 | //! whole of [`Ingest::handle`] is synchronous, and a full writer channel is |
| 10 | //! answered rather than waited on. Applying backpressure here would turn one slow |
| 11 | //! disk into packet loss for every device at once. |
| 12 | |
| 13 | use std::net::SocketAddr; |
| 14 | use std::sync::Arc; |
| 15 | |
| 16 | use anyhow::{Context, Result}; |
| 17 | use socket2::{Domain, Protocol, Socket, Type}; |
| 18 | use tokio::net::UdpSocket; |
| 19 | use tracing::{error, info, warn}; |
| 20 | |
| 21 | use crate::db::now; |
| 22 | use crate::ingest::{Ingest, Peer, Transport}; |
| 23 | |
| 24 | /// Receive buffer per socket. A queue flush from every device at once arrives as |
| 25 | /// a burst, and the kernel's default (a few hundred kB) drops it on the floor |
| 26 | /// before this process ever sees it. |
| 27 | const RECV_BUFFER_BYTES: usize = 2 * 1024 * 1024; |
| 28 | |
| 29 | /// One more than the largest datagram, so an oversized packet is *seen* to be |
| 30 | /// oversized instead of being silently truncated into something that might parse. |
| 31 | const READ_BUFFER: usize = otproto::MAX_DATAGRAM + 1; |
| 32 | |
| 33 | fn bind_reuseport(addr: SocketAddr) -> Result<UdpSocket> { |
| 34 | let domain = if addr.is_ipv6() { |
| 35 | Domain::IPV6 |
| 36 | } else { |
| 37 | Domain::IPV4 |
| 38 | }; |
| 39 | let socket = |
| 40 | Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)).context("creating socket")?; |
| 41 | socket.set_reuse_address(true).context("SO_REUSEADDR")?; |
| 42 | socket.set_reuse_port(true).context("SO_REUSEPORT")?; |
| 43 | |
| 44 | // Best effort: on Linux the kernel doubles the requested value and caps it at |
| 45 | // net.core.rmem_max, so a smaller buffer than asked for is normal and not |
| 46 | // worth failing startup over. |
| 47 | if let Err(e) = socket.set_recv_buffer_size(RECV_BUFFER_BYTES) { |
| 48 | warn!(error = %e, "could not enlarge the UDP receive buffer; bursts may be dropped"); |
| 49 | } |
| 50 | |
| 51 | socket.set_nonblocking(true).context("set_nonblocking")?; |
| 52 | socket |
| 53 | .bind(&addr.into()) |
| 54 | .with_context(|| format!("binding {addr}/udp"))?; |
| 55 | UdpSocket::from_std(socket.into()).context("handing the socket to tokio") |
| 56 | } |
| 57 | |
| 58 | /// Spawn `workers` receive tasks on `addr`. |
| 59 | pub fn spawn( |
| 60 | addr: SocketAddr, |
| 61 | workers: usize, |
| 62 | ingest: Arc<Ingest>, |
| 63 | ) -> Result<Vec<tokio::task::JoinHandle<()>>> { |
| 64 | let mut tasks = Vec::with_capacity(workers); |
| 65 | for id in 0..workers { |
| 66 | let socket = bind_reuseport(addr)?; |
| 67 | tasks.push(tokio::spawn(run(id, socket, Arc::clone(&ingest)))); |
| 68 | } |
| 69 | info!(%addr, workers, "OTP/1 UDP listener started"); |
| 70 | // The trap worth stating in the log, because operators hit it on day one and |
| 71 | // the symptom (everything falls back to TLS) is far from the cause. |
| 72 | info!("reminder: HTTP reverse proxies do not forward UDP — {addr} needs its own firewall rule"); |
| 73 | Ok(tasks) |
| 74 | } |
| 75 | |
| 76 | async fn run(id: usize, socket: UdpSocket, ingest: Arc<Ingest>) { |
| 77 | let mut buf = vec![0u8; READ_BUFFER]; |
| 78 | loop { |
| 79 | let (len, peer_addr) = match socket.recv_from(&mut buf).await { |
| 80 | Ok(v) => v, |
| 81 | Err(e) => { |
| 82 | // On UDP a send error can surface here as ICMP-driven |
| 83 | // ECONNREFUSED for a *previous* send. It says nothing about the |
| 84 | // socket's health, so log and keep going rather than exiting the |
| 85 | // worker and silently losing a quarter of the capacity. |
| 86 | warn!(worker = id, error = %e, "recv_from failed"); |
| 87 | continue; |
| 88 | } |
| 89 | }; |
| 90 | |
| 91 | let peer = Peer { |
| 92 | addr: peer_addr, |
| 93 | transport: Transport::Udp, |
| 94 | }; |
| 95 | if let Some(reply) = ingest.handle(&buf[..len], peer, now()) |
| 96 | && let Err(e) = socket.send_to(&reply, peer_addr).await |
| 97 | { |
| 98 | // The phone will retry; there is nothing to recover here. |
| 99 | error!(worker = id, %peer_addr, error = %e, "sending reply failed"); |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | #[cfg(test)] |
| 105 | mod tests { |
| 106 | use super::*; |
| 107 | use otproto::msg::Direction; |
| 108 | use otproto::{Key, Message, Point, kdf}; |
| 109 | use std::time::Duration; |
| 110 | |
| 111 | const TOKEN_ID: u64 = 0xABCD_0123_4567_89EF; |
| 112 | const TOKEN_KEY: Key = [0x33; 32]; |
| 113 | |
| 114 | /// Round trip a real datagram over a real loopback socket. This is the only |
| 115 | /// test that exercises the socket options and the reply path together. |
| 116 | #[tokio::test] |
| 117 | async fn a_datagram_over_loopback_is_acked() { |
| 118 | let dir = tempfile::tempdir().expect("temp dir"); |
| 119 | let db = crate::db::Db::open(&dir.path().join("t.db")) |
| 120 | .await |
| 121 | .expect("open"); |
| 122 | sqlx::query( |
| 123 | "INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \ |
| 124 | VALUES (1, 'a', 'x', 'A', 0, 0)", |
| 125 | ) |
| 126 | .execute(&db.write) |
| 127 | .await |
| 128 | .expect("user"); |
| 129 | |
| 130 | let (writer, _task) = crate::writer::spawn(db.write.clone()); |
| 131 | let ingest = Arc::new(Ingest::new(writer, 30 * 86_400, None)); |
| 132 | ingest.insert_token(crate::ingest::TokenSlot::new(TOKEN_ID, 1, &TOKEN_KEY, 1)); |
| 133 | |
| 134 | // Port 0 lets the OS choose; then read it back, because SO_REUSEPORT |
| 135 | // workers must all bind the *same* concrete port. |
| 136 | let probe = bind_reuseport("127.0.0.1:0".parse().expect("literal")).expect("bind"); |
| 137 | let addr = probe.local_addr().expect("local addr"); |
| 138 | drop(probe); |
| 139 | |
| 140 | let _tasks = spawn(addr, 2, Arc::clone(&ingest)).expect("spawn"); |
| 141 | |
| 142 | let client = UdpSocket::bind("127.0.0.1:0").await.expect("client bind"); |
| 143 | let k_up = kdf::derive(&TOKEN_KEY, Direction::Up); |
| 144 | let msg = Message::Loc(vec![Point { |
| 145 | acc_dm: Some(50), |
| 146 | ..Point::new(now() as u32, 525_200_080, 134_050_000) |
| 147 | }]); |
| 148 | let datagram = otproto::seal_message(&k_up, TOKEN_ID, [0x77; 12], &msg); |
| 149 | client.send_to(&datagram, addr).await.expect("send"); |
| 150 | |
| 151 | let mut buf = vec![0u8; READ_BUFFER]; |
| 152 | let len = tokio::time::timeout(Duration::from_secs(2), client.recv(&mut buf)) |
| 153 | .await |
| 154 | .expect("no reply within 2s") |
| 155 | .expect("recv"); |
| 156 | |
| 157 | let k_down = kdf::derive(&TOKEN_KEY, Direction::Down); |
| 158 | match otproto::open_message(&k_down, &buf[..len]) |
| 159 | .expect("ack opens") |
| 160 | .1 |
| 161 | { |
| 162 | Message::Ack(ack) => assert_eq!(ack.nonces, vec![[0x77u8; 12]]), |
| 163 | other => panic!("expected an ACK, got {other:?}"), |
| 164 | } |
| 165 | assert!(len <= datagram.len(), "the reply amplified the request"); |
| 166 | } |
| 167 | |
| 168 | #[tokio::test] |
| 169 | async fn several_workers_can_share_one_port() { |
| 170 | let probe = bind_reuseport("127.0.0.1:0".parse().expect("literal")).expect("first bind"); |
| 171 | let addr = probe.local_addr().expect("local addr"); |
| 172 | // The second bind on the same concrete port is the thing SO_REUSEPORT |
| 173 | // makes legal, and the thing the whole multi-worker design rests on. |
| 174 | let second = bind_reuseport(addr).expect("SO_REUSEPORT should allow a second bind"); |
| 175 | assert_eq!(second.local_addr().expect("addr"), addr); |
| 176 | } |
| 177 | } |
| 178 |