1use 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
30pub 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
40pub const DRAIN_STATE_DIR_ENV: &str = "HATCHERY_DRAIN_STATE_DIR";
42
43const CURSOR_RESET_AFTER: Duration = Duration::from_secs(20);
46
47const NUDGE_EVERY: Duration = Duration::from_secs(3);
49
50#[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 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 outbound: Option<tokio::sync::mpsc::Receiver<Vec<u8>>>,
102 cursor: Option<CursorStore>,
103 cursor_write_failed: bool,
104 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 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 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 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 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 self.flush_pending(&mut s).await?;
215 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 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 Err(err) => tracing::debug!(error = %err, "handshake refused"),
261 }
262 }
263 Some((InnerKind::Transport, ct)) => {
264 if self.pending.is_some() {
265 return Ok(());
268 }
269 let Some(transport) = self.transport.as_mut() else {
270 return self.nudge(s).await;
272 };
273 let Ok(plain) = transport.decrypt(ct) else {
274 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 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}