Skip to main content

hatchery_server/
drain_consumer.rs

1//! Core drain consumer (A2 / A3): core dials C2, drains one node stream, and answers on
2//! the same stream. C2 never dials core and never sees plaintext.
3//!
4//! Per stream: Noise_NK msg1 arrives as a DATA envelope, core answers msg2 as downlink
5//! DATA, then every envelope is Noise transport. Each plaintext goes to a
6//! [`CoreHandler`] together with the node id taken from the stream (never from the
7//! payload); the reply is encrypted back down. Every processed seq is ACKed, and a
8//! reconnect RESUMEs after the last ACK.
9//!
10//! Restart safety: the last ACK per node/channel is persisted (see [`CursorStore`]) so a
11//! restarted core does not replay the node's stream from zero. When a replay still
12//! happens (no cursor, C2 reset), only the newest Handshake1 of the backlog is answered:
13//! a Handshake1 is held until the backlog goes idle, a newer one replaces it, and
14//! transport frames queued behind an unanswered Handshake1 belong to an older session
15//! and are dropped. If no handshake lands for a while after resuming from a persisted
16//! cursor (C2 restarted and its seqs restarted), the cursor is reset to zero.
17//! A node does not notice a core restart (its C2 uplink stays up), so when core holds
18//! no session it sends an undecryptable transport frame; the node drops its stale keys
19//! and re-handshakes.
20
21use hatchery_c2_client::{DrainClient, DrainSession};
22use hatchery_core::inner::{decode_inner, encode_inner, InnerKind};
23use hatchery_core::nested::{HqRecipientSecret, NoiseResponder, NoiseTransport};
24use hatchery_core::ChannelKind;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use std::time::Duration;
28use tokio::sync::watch;
29
30/// Core logic for one decrypted inner message. Blocking work is fine: it runs on a
31/// blocking thread. Return `None` for messages that need no reply.
32pub type CoreHandler = Arc<dyn Fn(&str, ChannelKind, &[u8]) -> Option<Vec<u8>> + Send + Sync>;
33
34pub struct NodeStream {
35    pub node_id: String,
36    pub channel_id: u32,
37    pub kind: ChannelKind,
38}
39
40/// Env var naming the directory for persisted drain cursors (absolute path).
41pub const DRAIN_STATE_DIR_ENV: &str = "HATCHERY_DRAIN_STATE_DIR";
42
43/// How long after resuming from a persisted cursor we wait for a handshake before
44/// assuming C2's sequence space restarted and replaying from zero.
45const CURSOR_RESET_AFTER: Duration = Duration::from_secs(20);
46
47/// Minimum gap between two rekey nudges on one stream.
48const NUDGE_EVERY: Duration = Duration::from_secs(3);
49
50/// File-backed last-ACK cursor for one node stream. Writes are atomic (tmp + rename).
51#[derive(Clone, Debug)]
52pub struct CursorStore {
53    path: PathBuf,
54}
55
56impl CursorStore {
57    pub fn new(dir: &Path, node_id: &str, channel_id: u32) -> Self {
58        let safe: String = node_id
59            .chars()
60            .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
61            .collect();
62        Self { path: dir.join(format!("{safe}.{channel_id}.cursor")) }
63    }
64
65    /// Resolve the cursor directory: `HATCHERY_DRAIN_STATE_DIR`, else
66    /// `<HATCHERY_SERVER_DATA_DIR>/drain-cursors`, else none (memory only).
67    pub fn dir_from_env() -> Option<PathBuf> {
68        let abs = |v: String| Some(PathBuf::from(v)).filter(|p| p.is_absolute());
69        if let Some(d) = std::env::var(DRAIN_STATE_DIR_ENV).ok().filter(|s| !s.is_empty()).and_then(abs) {
70            return Some(d);
71        }
72        std::env::var(crate::config::DATA_DIR_ENV)
73            .ok()
74            .filter(|s| !s.is_empty())
75            .and_then(abs)
76            .map(|d| d.join("drain-cursors"))
77    }
78
79    pub fn load(&self) -> u64 {
80        std::fs::read_to_string(&self.path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0)
81    }
82
83    pub fn save(&self, seq: u64) -> std::io::Result<()> {
84        if let Some(dir) = self.path.parent() {
85            std::fs::create_dir_all(dir)?;
86        }
87        let tmp = self.path.with_extension("cursor.tmp");
88        std::fs::write(&tmp, seq.to_string())?;
89        std::fs::rename(&tmp, &self.path)
90    }
91}
92
93pub struct DrainConsumer {
94    client: DrainClient,
95    recipient: HqRecipientSecret,
96    stream: NodeStream,
97    handler: CoreHandler,
98    last_acked: u64,
99    transport: Option<NoiseTransport>,
100    /// Core-initiated messages (operator control). Sent once the node's handshake landed.
101    outbound: Option<tokio::sync::mpsc::Receiver<Vec<u8>>>,
102    cursor: Option<CursorStore>,
103    cursor_write_failed: bool,
104    /// Answer for the newest Handshake1 seen, held until the backlog is drained.
105    pending: Option<(Vec<u8>, NoiseTransport)>,
106    last_nudge: Option<tokio::time::Instant>,
107}
108
109impl DrainConsumer {
110    pub fn new(client: DrainClient, recipient: HqRecipientSecret, stream: NodeStream, handler: CoreHandler) -> Self {
111        Self {
112            client,
113            recipient,
114            stream,
115            handler,
116            last_acked: 0,
117            transport: None,
118            outbound: None,
119            cursor: None,
120            cursor_write_failed: false,
121            pending: None,
122            last_nudge: None,
123        }
124    }
125
126    /// Persist the last ACK under `dir` and start from the stored value.
127    pub fn with_cursor_dir(mut self, dir: Option<PathBuf>) -> Self {
128        if let Some(dir) = dir {
129            let store = CursorStore::new(&dir, &self.stream.node_id, self.stream.channel_id);
130            self.last_acked = store.load();
131            if self.last_acked > 0 {
132                tracing::info!(node = %self.stream.node_id, channel = self.stream.channel_id, cursor = self.last_acked, "drain cursor restored");
133            }
134            self.cursor = Some(store);
135        }
136        self
137    }
138
139    fn set_acked(&mut self, seq: u64) {
140        self.last_acked = seq;
141        if let Some(store) = &self.cursor {
142            match store.save(seq) {
143                Ok(()) => self.cursor_write_failed = false,
144                Err(err) if !self.cursor_write_failed => {
145                    self.cursor_write_failed = true;
146                    tracing::warn!(node = %self.stream.node_id, error = %err, "drain cursor not persisted");
147                }
148                Err(_) => {}
149            }
150        }
151    }
152
153    /// Attach a queue of core-initiated plaintexts (see `node_controller::OperatorLink`).
154    pub fn with_outbound(mut self, rx: tokio::sync::mpsc::Receiver<Vec<u8>>) -> Self {
155        self.outbound = Some(rx);
156        self
157    }
158
159    pub fn last_acked(&self) -> u64 {
160        self.last_acked
161    }
162
163    /// Drain until shutdown, reconnecting with RESUME after the last ACK.
164    pub async fn run(mut self, mut shutdown: watch::Receiver<bool>) {
165        let mut backoff = Duration::from_millis(50);
166        while !*shutdown.borrow() {
167            match self.session(&mut shutdown).await {
168                Ok(()) => backoff = Duration::from_millis(50),
169                Err(err) => tracing::warn!(node = %self.stream.node_id, channel = self.stream.channel_id, error = %err, "drain session ended"),
170            }
171            if *shutdown.borrow() {
172                break;
173            }
174            tokio::select! {
175                _ = tokio::time::sleep(backoff) => {}
176                _ = shutdown.changed() => {}
177            }
178            backoff = (backoff * 2).min(Duration::from_secs(5));
179        }
180    }
181
182    async fn session(&mut self, shutdown: &mut watch::Receiver<bool>) -> Result<(), String> {
183        // Prior Noise keys are invalid after a TCP reconnect; wait for a fresh msg1.
184        self.transport = None;
185        self.pending = None;
186        let resumed_from = self.last_acked;
187        let started = tokio::time::Instant::now();
188        let mut s = self.client.connect().await.map_err(|e| e.to_string())?;
189        s.open(&self.stream.node_id, self.stream.channel_id, self.stream.kind).await.map_err(|e| e.to_string())?;
190        s.resume(self.last_acked).await.map_err(|e| e.to_string())?;
191        loop {
192            if *shutdown.borrow() {
193                return Ok(());
194            }
195            let ready = self.transport.is_some();
196            let env = tokio::select! {
197                env = s.recv_envelope(Duration::from_millis(250)) => env.map_err(|e| e.to_string())?,
198                Some(plain) = async {
199                    match self.outbound.as_mut() {
200                        Some(rx) if ready => rx.recv().await,
201                        _ => std::future::pending().await,
202                    }
203                } => {
204                    let ct = self.transport.as_mut().expect("transport").encrypt(&plain).map_err(|e| e.to_string())?;
205                    s.send_data(self.stream.channel_id, encode_inner(InnerKind::Transport, &ct))
206                        .await
207                        .map_err(|e| e.to_string())?;
208                    continue;
209                }
210                _ = shutdown.changed() => return Ok(()),
211            };
212            let Some(env) = env else {
213                // Backlog drained: answer only the newest Handshake1.
214                self.flush_pending(&mut s).await?;
215                // Idle without a session: the node may still hold keys from before our
216                // restart and will never re-handshake on its own. Nudge it.
217                if self.transport.is_none() && started.elapsed() >= Duration::from_secs(1) {
218                    self.nudge(&mut s).await?;
219                }
220                if resumed_from > 0
221                    && self.transport.is_none()
222                    && self.last_acked == resumed_from
223                    && started.elapsed() >= CURSOR_RESET_AFTER
224                {
225                    tracing::warn!(node = %self.stream.node_id, channel = self.stream.channel_id, cursor = resumed_from, "no handshake after resume; resetting drain cursor to 0");
226                    self.set_acked(0);
227                    return Err("drain cursor reset".into());
228                }
229                continue;
230            };
231            if env.node_id != self.stream.node_id {
232                continue;
233            }
234            let seq = env.frame.seq;
235            if seq != 0 && seq <= self.last_acked {
236                continue;
237            }
238            self.handle(&mut s, &env.frame.payload).await?;
239            if seq != 0 {
240                s.ack(seq).await.map_err(|e| e.to_string())?;
241                self.set_acked(seq);
242            }
243        }
244    }
245
246    async fn handle(&mut self, s: &mut DrainSession, inner: &[u8]) -> Result<(), String> {
247        match decode_inner(inner) {
248            Some((InnerKind::Handshake1, msg1)) => {
249                let responder = NoiseResponder::new(&self.recipient).map_err(|e| e.to_string())?;
250                match responder.complete(msg1) {
251                    Ok((msg2, transport)) => {
252                        // Hold the answer: a newer Handshake1 later in the backlog wins.
253                        if self.pending.is_some() {
254                            tracing::debug!(node = %self.stream.node_id, "stale handshake superseded");
255                        }
256                        self.transport = None;
257                        self.pending = Some((msg2, transport));
258                    }
259                    // Wrong recipient key or garbage: drop it, keep draining.
260                    Err(err) => tracing::debug!(error = %err, "handshake refused"),
261                }
262            }
263            Some((InnerKind::Transport, ct)) => {
264                if self.pending.is_some() {
265                    // The node cannot send transport before it got our msg2, so this
266                    // frame belongs to an older session.
267                    return Ok(());
268                }
269                let Some(transport) = self.transport.as_mut() else {
270                    // Live traffic from a session we hold no keys for (core restarted).
271                    return self.nudge(s).await;
272                };
273                let Ok(plain) = transport.decrypt(ct) else {
274                    // Desync (often after C2 dropped a frame under credit pressure).
275                    // Forget the session so the next Handshake1 can recover.
276                    tracing::warn!(node = %self.stream.node_id, channel = self.stream.channel_id, "inner decrypt failed; resetting Noise session");
277                    self.transport = None;
278                    return Ok(());
279                };
280                let handler = Arc::clone(&self.handler);
281                let node = self.stream.node_id.clone();
282                let kind = self.stream.kind;
283                let reply = tokio::task::spawn_blocking(move || handler(&node, kind, &plain))
284                    .await
285                    .map_err(|e| e.to_string())?;
286                if let Some(reply) = reply {
287                    let ct = self.transport.as_mut().expect("transport").encrypt(&reply).map_err(|e| e.to_string())?;
288                    s.send_data(self.stream.channel_id, encode_inner(InnerKind::Transport, &ct))
289                        .await
290                        .map_err(|e| e.to_string())?;
291                }
292            }
293            _ => {}
294        }
295        Ok(())
296    }
297
298    /// Send an undecryptable transport frame: the node's session fails to decrypt it,
299    /// drops its stale keys and reconnects with a fresh Handshake1. Rate-limited.
300    async fn nudge(&mut self, s: &mut DrainSession) -> Result<(), String> {
301        if self.pending.is_some() || self.last_nudge.is_some_and(|t| t.elapsed() < NUDGE_EVERY) {
302            return Ok(());
303        }
304        self.last_nudge = Some(tokio::time::Instant::now());
305        tracing::info!(node = %self.stream.node_id, channel = self.stream.channel_id, "no Noise session; asking node to re-handshake");
306        s.send_data(self.stream.channel_id, encode_inner(InnerKind::Transport, &[0u8; 48]))
307            .await
308            .map_err(|e| e.to_string())
309    }
310
311    async fn flush_pending(&mut self, s: &mut DrainSession) -> Result<(), String> {
312        if let Some((msg2, transport)) = self.pending.take() {
313            self.transport = Some(transport);
314            s.send_data(self.stream.channel_id, encode_inner(InnerKind::Handshake2, &msg2))
315                .await
316                .map_err(|e| e.to_string())?;
317            tracing::info!(node = %self.stream.node_id, channel = self.stream.channel_id, "drain handshake answered");
318        }
319        Ok(())
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn cursor_roundtrip_and_sanitized_name() {
329        let dir = tempfile::tempdir().unwrap();
330        let store = CursorStore::new(dir.path(), "../evil/node", 7);
331        assert_eq!(store.load(), 0);
332        store.save(42).unwrap();
333        assert_eq!(store.load(), 42);
334        assert_eq!(CursorStore::new(dir.path(), "../evil/node", 7).load(), 42);
335        assert_eq!(CursorStore::new(dir.path(), "../evil/node", 8).load(), 0);
336        assert!(store.path.starts_with(dir.path()));
337    }
338
339    #[test]
340    fn cursor_store_creates_dir() {
341        let dir = tempfile::tempdir().unwrap();
342        let store = CursorStore::new(&dir.path().join("a/b"), "n1", 1);
343        store.save(5).unwrap();
344        assert_eq!(store.load(), 5);
345    }
346}