Skip to main content

interlink/
bus.rs

1//! The broker: a durable, keep-until-acked FIFO per recipient over HTTP.
2//!
3//! The bus is deliberately dumb — it routes an opaque JSON payload to a
4//! recipient id, never inspects it, never verifies a signature, holds no keys.
5//! Messages **persist in a [`Store`] until the recipient acks them**, so a bus
6//! restart doesn't lose anything queued for an offline agent. Delivery is
7//! at-least-once; the recipient dedupes by `msg_id`, so a redelivered message is
8//! harmless.
9//!
10//! Recipients are Ed25519 public keys (base64). The bus treats them as strings.
11
12use std::sync::Arc;
13
14use axum::extract::{Query, State};
15use axum::response::{IntoResponse, Response};
16use axum::routing::{get, post};
17use axum::{Json, Router, http::StatusCode};
18use dashmap::DashMap;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use tokio::sync::Notify;
22use tokio::time::{Duration, timeout};
23
24use crate::route::Route;
25use crate::store::Store;
26
27pub const DEFAULT_RECV_TIMEOUT_MS: u64 = 25_000;
28
29/// How long a presence announcement is *retained* without a refresh. Generous —
30/// covering a multi-day laptop sleep — because a silent session may just be asleep,
31/// not gone (a graceful close removes it immediately via `/unregister`). Clients
32/// classify each entry live-vs-away from the `age_ms` the roster reports; this bound
33/// only decides when a long-silent entry is finally dropped. See `docs/PRESENCE.md`.
34pub const AWAY_RETAIN_MS: u64 = 3 * 24 * 60 * 60 * 1_000; // 3 days
35
36/// Cap on distinct roster entries, so an announcement flood can't grow it without
37/// limit. Far above any real mesh; expired entries are pruned first.
38const ROSTER_CAP: usize = 4096;
39
40/// When the wakeup map grows past this, prune idle entries so a stream of distinct
41/// (unauthenticated) recipient ids can't leak `Notify`s without bound.
42const NOTIFY_CAP: usize = 4096;
43
44/// A payload addressed to a recipient, stamped on arrival.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct Envelope {
47    pub payload: Value,
48    /// Unix milliseconds, set by the bus when the message was enqueued.
49    pub ts: u64,
50}
51
52#[derive(Clone)]
53pub struct Broker {
54    store: Store,
55    /// Per-recipient wakeups for the long-poll. In-memory and rebuildable — the
56    /// durable state is entirely in the store.
57    notifies: Arc<DashMap<String, Arc<Notify>>>,
58    /// Presence roster: `pubkey#session_id` → (opaque signed announcement,
59    /// received-at ms). Keyed per *session* so several live sessions under one
60    /// identity coexist instead of overwriting each other; clients group by the
61    /// announcement's `pubkey`. In-memory and ephemeral — the bus stores and serves
62    /// it but never verifies it; clients check the signatures. Just a bulletin board.
63    roster: Arc<DashMap<String, (Value, u64)>>,
64    cap: usize,
65}
66
67impl Broker {
68    pub fn new(store: Store, cap: usize) -> Self {
69        Self {
70            store,
71            notifies: Arc::new(DashMap::new()),
72            roster: Arc::new(DashMap::new()),
73            cap: cap.max(1),
74        }
75    }
76
77    /// Record a presence announcement under a per-session key (`pubkey#session_id`,
78    /// or a bare `pubkey` for a sessionless legacy announcement). Prunes expired
79    /// entries; the announcement is stored verbatim (the bus never inspects it
80    /// beyond the routing key).
81    pub fn announce(&self, key: String, announcement: Value, now: u64) {
82        self.roster
83            .retain(|_, (_, at)| now.saturating_sub(*at) < AWAY_RETAIN_MS);
84        if self.roster.len() >= ROSTER_CAP && !self.roster.contains_key(&key) {
85            return; // full of live entries; drop the newcomer rather than evict
86        }
87        self.roster.insert(key, (announcement, now));
88    }
89
90    /// Immediately drop a session's presence (a graceful close), so a peer learns
91    /// it's really gone rather than waiting out the TTL. Unsigned and best-effort:
92    /// the bus verifies nothing, and a still-live session simply re-announces on its
93    /// next heartbeat, so a spurious unregister is self-healing.
94    pub fn unregister(&self, key: &str) {
95        self.roster.remove(key);
96    }
97
98    /// The retained announcements, each stamped with an unsigned `age_ms` (how long
99    /// since its last refresh) so the client can classify it live vs. away. The
100    /// announcement's signed fields are untouched — `age_ms` is additive and ignored
101    /// by signature verification.
102    pub fn roster(&self, now: u64) -> Vec<Value> {
103        self.roster
104            .iter()
105            .filter_map(|e| {
106                let (ann, at) = e.value();
107                let age = now.saturating_sub(*at);
108                if age >= AWAY_RETAIN_MS {
109                    return None;
110                }
111                let mut v = ann.clone();
112                if let Some(obj) = v.as_object_mut() {
113                    obj.insert("age_ms".into(), json!(age));
114                }
115                Some(v)
116            })
117            .collect()
118    }
119
120    fn notify_handle(&self, id: &str) -> Arc<Notify> {
121        // Before adding a new id, prune idle notifies when the map is large. An entry
122        // whose Arc has no clone outside the map (`strong_count == 1`) has no parked
123        // waiter, so dropping it can't lose a wakeup — a later waiter recreates it and
124        // peeks the durable store directly. Bounds an otherwise-unbounded leak from a
125        // flood of distinct recipient ids on the unauthenticated `/send`//`/recv`.
126        if self.notifies.len() > NOTIFY_CAP && !self.notifies.contains_key(id) {
127            self.notifies.retain(|_, v| Arc::strong_count(v) > 1);
128        }
129        self.notifies
130            .entry(id.to_string())
131            .or_insert_with(|| Arc::new(Notify::new()))
132            .clone()
133    }
134
135    /// Enqueue for `to`: persist, enforce the cap (drop oldest), wake a waiter.
136    pub async fn enqueue(&self, to: &str, payload: Value, ts: u64) -> anyhow::Result<()> {
137        let bytes = serde_json::to_vec(&Envelope { payload, ts })?;
138        self.store.enqueue(to.to_string(), bytes).await?;
139        // Bounded: drop oldest beyond the cap so a never-returning recipient
140        // can't grow the store without limit.
141        while self.store.depth(to.to_string()).await? > self.cap {
142            match self.store.peek_oldest(to.to_string()).await? {
143                Some((old_key, _)) => {
144                    self.store.ack(old_key).await?;
145                    tracing::warn!(to, cap = self.cap, "queue full; dropped oldest");
146                }
147                None => break,
148            }
149        }
150        self.notify_handle(to).notify_one();
151        Ok(())
152    }
153
154    /// Wait up to `wait` for the oldest un-acked message for `id`. Returns the
155    /// envelope and its ack key; the message stays in the store until `ack`.
156    pub async fn recv(
157        &self,
158        id: &str,
159        wait: Duration,
160    ) -> anyhow::Result<Option<(Envelope, String)>> {
161        let deadline = tokio::time::Instant::now() + wait;
162        let notify = self.notify_handle(id);
163        loop {
164            if let Some((key, bytes)) = self.store.peek_oldest(id.to_string()).await? {
165                return Ok(Some((serde_json::from_slice(&bytes)?, key)));
166            }
167            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
168            if remaining.is_zero() {
169                return Ok(None);
170            }
171            // Register interest before re-checking, or a message enqueued in
172            // between would not wake us — the classic lost-wakeup.
173            let notified = notify.notified();
174            if timeout(remaining, notified).await.is_err() {
175                return Ok(None);
176            }
177        }
178    }
179
180    pub async fn ack(&self, key: &str) -> anyhow::Result<()> {
181        self.store.ack(key.to_string()).await
182    }
183
184    pub async fn depth(&self, id: &str) -> anyhow::Result<usize> {
185        self.store.depth(id.to_string()).await
186    }
187
188    pub fn router(self) -> Router {
189        Router::new()
190            .route("/send", post(send))
191            .route("/recv", get(recv_handler))
192            .route("/ack", post(ack_handler))
193            .route("/announce", post(announce_handler))
194            .route("/unregister", post(unregister_handler))
195            .route("/roster", get(roster_handler))
196            .with_state(self)
197    }
198}
199
200#[derive(Deserialize)]
201struct SendBody {
202    to: String,
203    payload: Value,
204}
205
206async fn send(State(broker): State<Broker>, Json(body): Json<SendBody>) -> StatusCode {
207    match broker
208        .enqueue(&body.to, body.payload, crate::now_ms())
209        .await
210    {
211        Ok(()) => StatusCode::ACCEPTED,
212        Err(e) => {
213            tracing::error!("enqueue failed: {e}");
214            StatusCode::INTERNAL_SERVER_ERROR
215        }
216    }
217}
218
219#[derive(Deserialize)]
220struct RecvQuery {
221    me: String,
222    #[serde(default = "default_timeout")]
223    timeout_ms: u64,
224}
225
226fn default_timeout() -> u64 {
227    DEFAULT_RECV_TIMEOUT_MS
228}
229
230async fn recv_handler(State(broker): State<Broker>, Query(q): Query<RecvQuery>) -> Response {
231    match broker
232        .recv(&q.me, Duration::from_millis(q.timeout_ms))
233        .await
234    {
235        Ok(Some((env, ack))) => {
236            Json(json!({ "status": "message", "envelope": env, "ack": ack })).into_response()
237        }
238        Ok(None) => Json(json!({ "status": "timeout" })).into_response(),
239        Err(e) => {
240            tracing::error!("recv failed: {e}");
241            // A 5xx (not a 200 with an error body) so the client's
242            // error_for_status() trips and it backs off instead of hot-looping.
243            (
244                StatusCode::INTERNAL_SERVER_ERROR,
245                Json(json!({ "status": "error" })),
246            )
247                .into_response()
248        }
249    }
250}
251
252#[derive(Deserialize)]
253struct AckBody {
254    me: String,
255    ack: String,
256}
257
258async fn ack_handler(State(broker): State<Broker>, Json(body): Json<AckBody>) -> StatusCode {
259    // The ack key encodes its recipient; only let `me` ack their own messages.
260    // Weak, but consistent with the bus's threat model (loopback/tailnet, no
261    // transport auth — signatures are what actually protect message integrity).
262    if !body.ack.starts_with(&format!("{}\u{0}", body.me)) {
263        return StatusCode::FORBIDDEN;
264    }
265    match broker.ack(&body.ack).await {
266        Ok(()) => StatusCode::OK,
267        Err(e) => {
268            tracing::error!("ack failed: {e}");
269            StatusCode::INTERNAL_SERVER_ERROR
270        }
271    }
272}
273
274/// The roster key: `pubkey#session_id`, or a bare `pubkey` when the announcement
275/// carries no session (a legacy node). The pubkey is the only field the bus needs
276/// to read; the rest (name, session, ts, sig) is opaque to it.
277fn roster_key(body: &Value) -> Option<String> {
278    let pubkey = body.get("pubkey").and_then(|v| v.as_str())?;
279    let session_id = body
280        .get("session")
281        .and_then(|s| s.get("session_id"))
282        .and_then(|v| v.as_str())
283        .unwrap_or_default();
284    Some(Route::new(pubkey, session_id).to_string())
285}
286
287/// The announcement is stored verbatim under its per-session roster key.
288async fn announce_handler(State(broker): State<Broker>, Json(body): Json<Value>) -> StatusCode {
289    let Some(key) = roster_key(&body) else {
290        return StatusCode::BAD_REQUEST;
291    };
292    broker.announce(key, body, crate::now_ms());
293    StatusCode::ACCEPTED
294}
295
296/// Graceful presence removal. Takes the same `{ pubkey, session }` shape as an
297/// announcement (the sig is ignored — see [`Broker::unregister`]).
298async fn unregister_handler(State(broker): State<Broker>, Json(body): Json<Value>) -> StatusCode {
299    let Some(key) = roster_key(&body) else {
300        return StatusCode::BAD_REQUEST;
301    };
302    broker.unregister(&key);
303    StatusCode::ACCEPTED
304}
305
306async fn roster_handler(State(broker): State<Broker>) -> Response {
307    Json(json!({ "roster": broker.roster(crate::now_ms()) })).into_response()
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn broker(cap: usize) -> Broker {
315        Broker::new(Store::in_memory().unwrap(), cap)
316    }
317
318    #[tokio::test]
319    async fn enqueue_recv_ack_roundtrip() {
320        let b = broker(8);
321        b.enqueue("alice", json!({ "hi": 1 }), 5).await.unwrap();
322        let (env, ack) = b
323            .recv("alice", Duration::from_millis(50))
324            .await
325            .unwrap()
326            .unwrap();
327        assert_eq!(env.payload, json!({ "hi": 1 }));
328        assert_eq!(env.ts, 5);
329        // keep-until-ack: still there before ack
330        assert_eq!(b.depth("alice").await.unwrap(), 1);
331        b.ack(&ack).await.unwrap();
332        assert_eq!(b.depth("alice").await.unwrap(), 0);
333    }
334
335    #[tokio::test]
336    async fn redelivers_until_acked() {
337        let b = broker(8);
338        b.enqueue("alice", json!("x"), 1).await.unwrap();
339        let (_e1, ack) = b
340            .recv("alice", Duration::from_millis(50))
341            .await
342            .unwrap()
343            .unwrap();
344        // A second recv without acking sees the SAME message again.
345        let (_e2, ack2) = b
346            .recv("alice", Duration::from_millis(50))
347            .await
348            .unwrap()
349            .unwrap();
350        assert_eq!(ack, ack2);
351        b.ack(&ack).await.unwrap();
352        assert!(
353            b.recv("alice", Duration::from_millis(20))
354                .await
355                .unwrap()
356                .is_none()
357        );
358    }
359
360    #[tokio::test]
361    async fn recv_times_out_when_empty() {
362        let b = broker(8);
363        assert!(
364            b.recv("nobody", Duration::from_millis(10))
365                .await
366                .unwrap()
367                .is_none()
368        );
369    }
370
371    #[tokio::test]
372    async fn fifo_order_across_acks() {
373        let b = broker(8);
374        for i in 0..3 {
375            b.enqueue("bob", json!(i), i).await.unwrap();
376        }
377        for i in 0..3 {
378            let (env, ack) = b
379                .recv("bob", Duration::from_millis(50))
380                .await
381                .unwrap()
382                .unwrap();
383            assert_eq!(env.payload, json!(i));
384            b.ack(&ack).await.unwrap();
385        }
386    }
387
388    #[tokio::test]
389    async fn bounded_queue_drops_oldest() {
390        let b = broker(2);
391        for i in 0..4 {
392            b.enqueue("bob", json!(i), i).await.unwrap();
393        }
394        assert_eq!(b.depth("bob").await.unwrap(), 2);
395        let (env, _) = b
396            .recv("bob", Duration::from_millis(50))
397            .await
398            .unwrap()
399            .unwrap();
400        assert_eq!(env.payload, json!(2), "0 and 1 were evicted");
401    }
402
403    #[tokio::test]
404    async fn a_waiting_recv_is_woken_by_a_later_send() {
405        let b = broker(8);
406        let b2 = b.clone();
407        let waiter = tokio::spawn(async move { b2.recv("alice", Duration::from_secs(2)).await });
408        tokio::time::sleep(Duration::from_millis(20)).await;
409        b.enqueue("alice", json!("wake"), 1).await.unwrap();
410        let (env, _) = waiter.await.unwrap().unwrap().unwrap();
411        assert_eq!(env.payload, json!("wake"));
412    }
413
414    #[test]
415    fn roster_stores_and_expires() {
416        let b = broker(8);
417        b.announce(
418            "keyA".into(),
419            json!({"pubkey":"keyA","name":"alice"}),
420            1_000,
421        );
422        b.announce("keyB".into(), json!({"pubkey":"keyB","name":"bob"}), 1_000);
423        assert_eq!(b.roster(1_000).len(), 2);
424        assert_eq!(
425            b.roster(1_000 + AWAY_RETAIN_MS - 1).len(),
426            2,
427            "retained within the away window"
428        );
429        assert_eq!(
430            b.roster(1_000 + AWAY_RETAIN_MS + 1).len(),
431            0,
432            "dropped past the retention bound"
433        );
434    }
435
436    #[test]
437    fn roster_stamps_age_ms() {
438        let b = broker(8);
439        b.announce(
440            "keyA".into(),
441            json!({"pubkey":"keyA","name":"alice"}),
442            1_000,
443        );
444        let r = b.roster(1_000 + 5_000);
445        assert_eq!(r.len(), 1);
446        assert_eq!(r[0]["age_ms"], json!(5_000u64), "age since last refresh");
447        // signed fields untouched
448        assert_eq!(r[0]["pubkey"], json!("keyA"));
449    }
450
451    #[test]
452    fn announce_upserts_by_pubkey() {
453        let b = broker(8);
454        b.announce("keyA".into(), json!({"pubkey":"keyA","name":"old"}), 1_000);
455        b.announce("keyA".into(), json!({"pubkey":"keyA","name":"new"}), 2_000);
456        let r = b.roster(2_000);
457        assert_eq!(r.len(), 1, "same pubkey replaces, not appends");
458        assert_eq!(r[0]["name"], "new");
459    }
460
461    #[test]
462    fn roster_key_scopes_by_session() {
463        let s1 = json!({"pubkey":"keyA","session":{"session_id":"aa11"}});
464        let s2 = json!({"pubkey":"keyA","session":{"session_id":"bb22"}});
465        assert_eq!(roster_key(&s1).unwrap(), "keyA#aa11");
466        assert_ne!(
467            roster_key(&s1),
468            roster_key(&s2),
469            "distinct sessions distinct"
470        );
471        // Legacy / sessionless announcement falls back to the bare pubkey.
472        assert_eq!(roster_key(&json!({"pubkey":"keyA"})).unwrap(), "keyA");
473        assert!(
474            roster_key(&json!({"name":"x"})).is_none(),
475            "pubkey required"
476        );
477    }
478
479    #[test]
480    fn two_sessions_under_one_identity_coexist() {
481        let b = broker(8);
482        let s1 = json!({"pubkey":"keyA","session":{"session_id":"aa11"}});
483        let s2 = json!({"pubkey":"keyA","session":{"session_id":"bb22"}});
484        b.announce(roster_key(&s1).unwrap(), s1, 1_000);
485        b.announce(roster_key(&s2).unwrap(), s2, 1_000);
486        assert_eq!(b.roster(1_000).len(), 2, "same identity, two live sessions");
487    }
488
489    #[test]
490    fn unregister_removes_one_session_immediately() {
491        let b = broker(8);
492        let s1 = json!({"pubkey":"keyA","session":{"session_id":"aa11"}});
493        let s2 = json!({"pubkey":"keyA","session":{"session_id":"bb22"}});
494        b.announce(roster_key(&s1).unwrap(), s1.clone(), 1_000);
495        b.announce(roster_key(&s2).unwrap(), s2, 1_000);
496        b.unregister(&roster_key(&s1).unwrap());
497        let r = b.roster(1_000);
498        assert_eq!(r.len(), 1, "only the unregistered session is gone");
499        assert_eq!(r[0]["session"]["session_id"], "bb22");
500    }
501}