Skip to main content

hatchery_c2/restreamer/
runtime.rs

1//! Restreamer runtime: node uplink + core drain, no push to core.
2//!
3//! Core opens a long-lived stream to C2 and drains envelopes. C2 never dials core.
4//! Nodes speak multiplex frames; payloads stay opaque after the outer crypto layer.
5
6use std::collections::BTreeMap;
7use std::net::SocketAddr;
8use std::sync::{Arc, Mutex};
9use std::time::Instant;
10
11use hatchery_core::{
12    ChannelKind, DrainEnvelope, Frame, FrameError, FrameType, JournalReplyBody, OpenPayload,
13    MAX_DATA_PAYLOAD,
14};
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tokio::net::{TcpListener, TcpStream};
17use tokio::sync::watch;
18
19use super::buffer::{EnqueueOutcome, ReplayBuffer, StreamKey};
20use super::crypto::{c2_strip_outer, RestreamerKeys};
21use super::journal::{recover_gap, NodeJournal};
22
23#[derive(Clone, Debug)]
24pub struct RestreamerConfig {
25    /// Node uplink listen (edge / WG tunnel address in production).
26    pub node_listen: SocketAddr,
27    /// Core drain listen (reachable only from the core tunnel).
28    pub drain_listen: SocketAddr,
29    pub buffer: super::buffer::BufferLimits,
30    /// Auth token core must present as the first line before draining.
31    /// Never logged.
32    pub core_token: String,
33}
34
35#[derive(Debug)]
36pub enum RestreamerError {
37    Io(std::io::Error),
38    Config(&'static str),
39    Frame(FrameError),
40}
41
42impl std::fmt::Display for RestreamerError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Io(e) => write!(f, "{e}"),
46            Self::Config(m) => write!(f, "{m}"),
47            Self::Frame(e) => write!(f, "{e}"),
48        }
49    }
50}
51
52impl std::error::Error for RestreamerError {}
53
54impl From<std::io::Error> for RestreamerError {
55    fn from(value: std::io::Error) -> Self {
56        Self::Io(value)
57    }
58}
59
60/// Live node uplinks so C2 can send JournalReq when recovering gaps.
61#[derive(Clone, Default)]
62pub struct UplinkPeers {
63    inner: Arc<Mutex<BTreeMap<String, PeerSlot>>>,
64}
65
66struct PeerSlot {
67    out: tokio::sync::mpsc::Sender<Frame>,
68    pending: Option<tokio::sync::oneshot::Sender<JournalReplyBody>>,
69}
70
71impl UplinkPeers {
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    pub fn register(&self, node_id: String, tx: tokio::sync::mpsc::Sender<Frame>) {
77        self.inner.lock().expect("peers").insert(
78            node_id,
79            PeerSlot {
80                out: tx,
81                pending: None,
82            },
83        );
84    }
85
86    pub fn unregister(&self, node_id: &str) {
87        self.inner.lock().expect("peers").remove(node_id);
88    }
89
90    pub fn deliver_reply(&self, node_id: &str, body: JournalReplyBody) {
91        let mut guard = self.inner.lock().expect("peers");
92        if let Some(slot) = guard.get_mut(node_id) {
93            if let Some(tx) = slot.pending.take() {
94                let _ = tx.send(body);
95            }
96        }
97    }
98
99    /// Relay one opaque downlink frame (core -> node). C2 does not read the payload.
100    pub async fn send_to(&self, node_id: &str, frame: Frame) -> Result<(), RestreamerError> {
101        let out = {
102            let guard = self.inner.lock().expect("peers");
103            guard.get(node_id).map(|slot| slot.out.clone())
104        };
105        let Some(out) = out else {
106            return Err(RestreamerError::Frame(FrameError::Io("node uplink absent".into())));
107        };
108        out.send(frame)
109            .await
110            .map_err(|_| RestreamerError::Frame(FrameError::Io("node uplink closed".into())))
111    }
112
113    pub fn is_registered(&self, node_id: &str) -> bool {
114        self.inner.lock().expect("peers").contains_key(node_id)
115    }
116
117    pub async fn request_journal(
118        &self,
119        node_id: &str,
120        channel_id: u32,
121        after: u64,
122        limit: u32,
123        timeout: std::time::Duration,
124    ) -> Result<JournalReplyBody, RestreamerError> {
125        let (tx, rx) = tokio::sync::oneshot::channel();
126        let out = {
127            let mut guard = self.inner.lock().expect("peers");
128            let Some(slot) = guard.get_mut(node_id) else {
129                return Err(RestreamerError::Frame(FrameError::Io(
130                    "node uplink absent".into(),
131                )));
132            };
133            slot.pending = Some(tx);
134            slot.out.clone()
135        };
136        out.send(Frame::journal_req(channel_id, after, limit))
137            .await
138            .map_err(|_| RestreamerError::Frame(FrameError::Io("node uplink closed".into())))?;
139        match tokio::time::timeout(timeout, rx).await {
140            Ok(Ok(body)) => Ok(body),
141            Ok(Err(_)) => Err(RestreamerError::Frame(FrameError::Io(
142                "journal reply dropped".into(),
143            ))),
144            Err(_) => Err(RestreamerError::Frame(FrameError::Io(
145                "journal reply timeout".into(),
146            ))),
147        }
148    }
149}
150
151struct Inner {
152    buffer: ReplayBuffer,
153    /// channel_id -> kind, for OPEN bookkeeping (metadata only).
154    channels: BTreeMap<(String, u32), ChannelKind>,
155    keys: Option<RestreamerKeys>,
156    /// True once any outbound dial toward a "core" address was attempted — must stay false.
157    pushed_to_core: bool,
158}
159
160/// Shared restreamer state.
161#[derive(Clone)]
162pub struct Restreamer {
163    /// Wakes core drain sessions as soon as an uplink frame is buffered.
164    uplink_ready: Arc<tokio::sync::Notify>,
165    inner: Arc<Mutex<Inner>>,
166    journal: Arc<dyn NodeJournal>,
167    peers: UplinkPeers,
168}
169
170impl Restreamer {
171    pub fn new(
172        buffer_limits: super::buffer::BufferLimits,
173        journal: Arc<dyn NodeJournal>,
174        core_token: impl Into<String>,
175        keys: Option<RestreamerKeys>,
176    ) -> Self {
177        let _ = core_token; // validated by callers / RestreamerConfig
178        Self {
179            uplink_ready: Arc::new(tokio::sync::Notify::new()),
180            inner: Arc::new(Mutex::new(Inner {
181                // Per-stream credit: a PTY burst (echo frames + replies) easily has >64
182                // messages in flight over the C2->core RTT; at 0 credit the frame is
183                // refused and, as the node does not replay it, the inner Noise stream to
184                // core desyncs and the session wedges. Hard limits stay max_events/bytes.
185                buffer: ReplayBuffer::new(buffer_limits).with_default_credit(1024),
186                channels: BTreeMap::new(),
187                keys,
188                pushed_to_core: false,
189            })),
190            journal,
191            peers: UplinkPeers::new(),
192        }
193    }
194
195    pub fn set_keys(&self, keys: RestreamerKeys) {
196        self.inner.lock().expect("lock").keys = Some(keys);
197    }
198
199    pub fn uplink_notify(&self) -> Arc<tokio::sync::Notify> {
200        self.uplink_ready.clone()
201    }
202
203    pub fn peers(&self) -> &UplinkPeers {
204        &self.peers
205    }
206
207    pub fn upstream_public_keys(&self) -> Option<(super::crypto::PublicKeyBytes, super::crypto::PublicKeyBytes)> {
208        self.inner
209            .lock()
210            .expect("lock")
211            .keys
212            .as_ref()
213            .map(|k| k.upstream_public_keys())
214    }
215
216    /// Invariant: C2 never opens connections to core.
217    pub fn ever_pushed_to_core(&self) -> bool {
218        self.inner.lock().expect("lock").pushed_to_core
219    }
220
221    /// Admit one opaque DATA payload from a node (outer layer already optional).
222    /// If keys are configured, `payload` is treated as outer-sealed and stripped.
223    pub fn admit_from_node(
224        &self,
225        node_id: &str,
226        channel_id: u32,
227        payload: Vec<u8>,
228    ) -> Result<EnqueueOutcome, RestreamerError> {
229        if payload.len() > MAX_DATA_PAYLOAD {
230            return Err(RestreamerError::Frame(FrameError::PayloadTooLarge(payload.len())));
231        }
232        let mut guard = self.inner.lock().expect("lock");
233        let opaque = if let Some(keys) = guard.keys.clone() {
234            c2_strip_outer(&keys, &payload).map_err(|e| {
235                RestreamerError::Frame(FrameError::Io(format!("outer layer: {e}")))
236            })?
237        } else {
238            payload
239        };
240        // Also record in journal view for gap recovery tests / local nodes.
241        let outcome = guard
242            .buffer
243            .enqueue(node_id, channel_id, opaque.clone(), Instant::now());
244        if let EnqueueOutcome::Accepted { .. } = outcome {
245            let seq = guard.buffer.next_seq(node_id, channel_id) - 1;
246            drop(guard);
247            // best-effort journal mirror when the journal is a MemoryJournal-compatible append —
248            // production journals live on the node; C2 asks via NodeJournal trait.
249            let _ = (seq, opaque);
250            self.uplink_ready.notify_waiters();
251        }
252        Ok(outcome)
253    }
254
255    pub fn open_channel(&self, node_id: &str, channel_id: u32, kind: ChannelKind) {
256        self.inner
257            .lock()
258            .expect("lock")
259            .channels
260            .insert((node_id.to_owned(), channel_id), kind);
261    }
262
263    pub fn ack(&self, node_id: &str, channel_id: u32, seq: u64) {
264        self.inner
265            .lock()
266            .expect("lock")
267            .buffer
268            .ack(node_id, channel_id, seq);
269    }
270
271    pub fn window_credit(&self, node_id: &str, channel_id: u32) -> u32 {
272        let key = StreamKey {
273            node_id: node_id.to_owned(),
274            channel_id,
275        };
276        self.inner.lock().expect("lock").buffer.credit(&key)
277    }
278
279    /// Produce drain envelopes for RESUME(after=N). Recovers gaps from the node journal.
280    pub fn resume(
281        &self,
282        node_id: &str,
283        channel_id: u32,
284        after: u64,
285    ) -> Result<Vec<DrainEnvelope>, RestreamerError> {
286        let (gap, oldest, buffered) = {
287            let guard = self.inner.lock().expect("lock");
288            let gap = guard.buffer.gap_before_buffer(node_id, channel_id, after);
289            let oldest = guard.buffer.oldest_seq(node_id, channel_id);
290            let buffered = guard.buffer.events_after(node_id, channel_id, after);
291            (gap, oldest, buffered)
292        };
293        let mut out = Vec::new();
294        if gap {
295            let until = oldest;
296            let recovered = recover_gap(
297                self.journal.as_ref(),
298                node_id,
299                channel_id,
300                after,
301                until,
302                1024,
303            )
304            .map_err(|e| RestreamerError::Frame(FrameError::Io(e.to_string())))?;
305            for ev in recovered {
306                out.push(DrainEnvelope {
307                    node_id: ev.node_id.clone(),
308                    frame: Frame::data(ev.channel_id, ev.seq, ev.payload)
309                        .map_err(RestreamerError::Frame)?,
310                });
311            }
312        }
313        for ev in buffered {
314            // skip duplicates already recovered
315            if out.iter().any(|e| e.frame.seq == ev.seq) {
316                continue;
317            }
318            out.push(DrainEnvelope {
319                node_id: ev.node_id.clone(),
320                frame: Frame::data(ev.channel_id, ev.seq, ev.payload)
321                    .map_err(RestreamerError::Frame)?,
322            });
323        }
324        Ok(out)
325    }
326
327    /// Prefer live uplink journal; fall back to the configured NodeJournal.
328    pub async fn resume_with_uplink(
329        &self,
330        node_id: &str,
331        channel_id: u32,
332        after: u64,
333    ) -> Result<Vec<DrainEnvelope>, RestreamerError> {
334        let (gap, oldest, buffered) = {
335            let guard = self.inner.lock().expect("lock");
336            let gap = guard.buffer.gap_before_buffer(node_id, channel_id, after);
337            let oldest = guard.buffer.oldest_seq(node_id, channel_id);
338            let buffered = guard.buffer.events_after(node_id, channel_id, after);
339            (gap, oldest, buffered)
340        };
341        let mut out = Vec::new();
342        if gap {
343            let until = oldest;
344            let recovered = if self.peers.is_registered(node_id) {
345                let body = self
346                    .peers
347                    .request_journal(
348                        node_id,
349                        channel_id,
350                        after,
351                        1024,
352                        std::time::Duration::from_secs(5),
353                    )
354                    .await?;
355                body.entries
356                    .into_iter()
357                    .filter(|e| until.map(|u| e.seq < u).unwrap_or(true))
358                    .map(|e| super::buffer::BufferedEvent {
359                        seq: e.seq,
360                        node_id: node_id.to_owned(),
361                        channel_id,
362                        payload: e.payload,
363                        enqueued_at: Instant::now(),
364                    })
365                    .collect()
366            } else {
367                recover_gap(
368                    self.journal.as_ref(),
369                    node_id,
370                    channel_id,
371                    after,
372                    until,
373                    1024,
374                )
375                .map_err(|e| RestreamerError::Frame(FrameError::Io(e.to_string())))?
376            };
377            for ev in recovered {
378                out.push(DrainEnvelope {
379                    node_id: ev.node_id.clone(),
380                    frame: Frame::data(ev.channel_id, ev.seq, ev.payload)
381                        .map_err(RestreamerError::Frame)?,
382                });
383            }
384        }
385        for ev in buffered {
386            if out.iter().any(|e| e.frame.seq == ev.seq) {
387                continue;
388            }
389            out.push(DrainEnvelope {
390                node_id: ev.node_id.clone(),
391                frame: Frame::data(ev.channel_id, ev.seq, ev.payload)
392                    .map_err(RestreamerError::Frame)?,
393            });
394        }
395        Ok(out)
396    }
397
398    /// Snapshot of buffered events for a stream (no journal).
399    pub fn buffered_after(&self, node_id: &str, channel_id: u32, after: u64) -> Vec<DrainEnvelope> {
400        let guard = self.inner.lock().expect("lock");
401        guard
402            .buffer
403            .events_after(node_id, channel_id, after)
404            .into_iter()
405            .filter_map(|ev| {
406                Frame::data(ev.channel_id, ev.seq, ev.payload)
407                    .ok()
408                    .map(|frame| DrainEnvelope {
409                        node_id: ev.node_id,
410                        frame,
411                    })
412            })
413            .collect()
414    }
415}
416
417/// Bind node uplink + core drain listeners and serve until shutdown.
418pub async fn serve(
419    config: RestreamerConfig,
420    restreamer: Restreamer,
421    mut shutdown: watch::Receiver<bool>,
422) -> Result<(), RestreamerError> {
423    if config.node_listen == config.drain_listen {
424        return Err(RestreamerError::Config(
425            "node_listen and drain_listen must differ",
426        ));
427    }
428    if config.core_token.len() < 16 {
429        return Err(RestreamerError::Config("core_token too short"));
430    }
431    let node_listener = TcpListener::bind(config.node_listen).await?;
432    let drain_listener = TcpListener::bind(config.drain_listen).await?;
433    loop {
434        tokio::select! {
435            _ = shutdown.changed() => {
436                if *shutdown.borrow() { break; }
437            }
438            accepted = node_listener.accept() => {
439                let (stream, _) = accepted?;
440                let _ = stream.set_nodelay(true);
441                let rs = restreamer.clone();
442                tokio::spawn(async move {
443                    if let Err(err) = handle_node_uplink(stream, rs).await {
444                        tracing::debug!(error = %err, "node uplink closed");
445                    }
446                });
447            }
448            accepted = drain_listener.accept() => {
449                let (stream, _) = accepted?;
450                let _ = stream.set_nodelay(true);
451                let rs = restreamer.clone();
452                let token = config.core_token.clone();
453                tokio::spawn(async move {
454                    if let Err(err) = handle_core_drain(stream, rs, &token).await {
455                        tracing::debug!(error = %err, "core drain closed");
456                    }
457                });
458            }
459        }
460    }
461    Ok(())
462}
463
464async fn handle_node_uplink(mut stream: TcpStream, rs: Restreamer) -> Result<(), RestreamerError> {
465    let mut buf = vec![0u8; 64 * 1024];
466    let mut filled = 0usize;
467    let mut node_id: Option<String> = None;
468    let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<Frame>(16);
469    loop {
470        tokio::select! {
471            biased;
472            maybe_out = out_rx.recv() => {
473                match maybe_out {
474                    Some(frame) => {
475                        stream
476                            .write_all(&frame.encode().map_err(RestreamerError::Frame)?)
477                            .await?;
478                    }
479                    None => break,
480                }
481            }
482            read = stream.read(&mut buf[filled..]) => {
483                let n = read?;
484                if n == 0 {
485                    break;
486                }
487                filled += n;
488                while filled > 0 {
489                    match Frame::decode(&buf[..filled]) {
490                        Ok((frame, consumed)) => {
491                            buf.copy_within(consumed..filled, 0);
492                            filled -= consumed;
493                            match frame.frame_type {
494                                FrameType::Open => {
495                                    let open = frame.parse_open().map_err(RestreamerError::Frame)?;
496                                    if let Some(prev) = node_id.replace(open.node_id.clone()) {
497                                        rs.peers().unregister(&prev);
498                                    }
499                                    rs.peers().register(open.node_id.clone(), out_tx.clone());
500                                    rs.open_channel(&open.node_id, frame.channel_id, open.channel_kind);
501                                    let credit = rs.window_credit(&open.node_id, frame.channel_id);
502                                    let win = Frame::window(frame.channel_id, credit);
503                                    stream
504                                        .write_all(&win.encode().map_err(RestreamerError::Frame)?)
505                                        .await?;
506                                }
507                                FrameType::Data => {
508                                    let Some(ref nid) = node_id else {
509                                        let go = Frame::goaway(1);
510                                        stream
511                                            .write_all(&go.encode().map_err(RestreamerError::Frame)?)
512                                            .await?;
513                                        return Ok(());
514                                    };
515                                    match rs.admit_from_node(nid, frame.channel_id, frame.payload)? {
516                                        EnqueueOutcome::Accepted { window_credit } => {
517                                            let win = Frame::window(frame.channel_id, window_credit);
518                                            stream
519                                                .write_all(
520                                                    &win.encode().map_err(RestreamerError::Frame)?,
521                                                )
522                                                .await?;
523                                        }
524                                        EnqueueOutcome::Backpressure => {
525                                            // Credit exhausted: frame is not buffered. The node
526                                            // does not replay, so a silent drop desyncs the inner
527                                            // Noise stream to core. Warn loudly; WINDOW 0 still
528                                            // tells the peer to pause.
529                                            tracing::warn!(
530                                                target: "restreamer",
531                                                node = %nid,
532                                                channel = frame.channel_id,
533                                                "restreamer credit exhausted; dropping node→core frame (Noise may desync)"
534                                            );
535                                            let win = Frame::window(frame.channel_id, 0);
536                                            stream
537                                                .write_all(
538                                                    &win.encode().map_err(RestreamerError::Frame)?,
539                                                )
540                                                .await?;
541                                        }
542                                    }
543                                }
544                                FrameType::JournalReply => {
545                                    if let Some(ref nid) = node_id {
546                                        let body = JournalReplyBody::decode(&frame.payload)
547                                            .map_err(RestreamerError::Frame)?;
548                                        rs.peers().deliver_reply(nid, body);
549                                    }
550                                }
551                                FrameType::Ping => {
552                                    let pong = Frame::pong(frame.seq);
553                                    stream
554                                        .write_all(&pong.encode().map_err(RestreamerError::Frame)?)
555                                        .await?;
556                                }
557                                FrameType::Close | FrameType::GoAway => {
558                                    if let Some(ref nid) = node_id {
559                                        rs.peers().unregister(nid);
560                                    }
561                                    return Ok(());
562                                }
563                                _ => {}
564                            }
565                        }
566                        Err(FrameError::Truncated) => break,
567                        Err(err) => return Err(RestreamerError::Frame(err)),
568                    }
569                }
570                if filled == buf.len() {
571                    return Err(RestreamerError::Frame(FrameError::PayloadTooLarge(filled)));
572                }
573            }
574        }
575    }
576    if let Some(ref nid) = node_id {
577        rs.peers().unregister(nid);
578    }
579    Ok(())
580}
581
582async fn handle_core_drain(
583    mut stream: TcpStream,
584    rs: Restreamer,
585    expected_token: &str,
586) -> Result<(), RestreamerError> {
587    // Auth: first line "TOKEN\n" (constant-time compare).
588    let mut auth = Vec::with_capacity(128);
589    loop {
590        let mut byte = [0u8; 1];
591        let n = stream.read(&mut byte).await?;
592        if n == 0 {
593            return Err(RestreamerError::Config("drain auth closed"));
594        }
595        if byte[0] == b'\n' {
596            break;
597        }
598        auth.push(byte[0]);
599        if auth.len() > 512 {
600            return Err(RestreamerError::Config("drain auth too long"));
601        }
602    }
603    if !tokens_equal(&auth, expected_token.as_bytes()) {
604        let go = Frame::goaway(403);
605        let _ = stream.write_all(&go.encode().unwrap_or_default()).await;
606        return Err(RestreamerError::Config("drain auth refused"));
607    }
608
609    let mut buf = vec![0u8; 64 * 1024];
610    let mut filled = 0usize;
611    // Default stream selection via OPEN from core (opaque node id).
612    let mut selected: Option<(String, u32)> = None;
613    // Highest seq already written on this drain session (per selected stream).
614    let mut sent_through: u64 = 0;
615    // Core must RESUME once before we drain; reconnects always start with RESUME.
616    let mut resumed = false;
617    let uplink_ready = rs.uplink_notify();
618    loop {
619        // Register for the wakeup before checking the buffer so none is missed.
620        let woken = uplink_ready.notified();
621        tokio::pin!(woken);
622        woken.as_mut().enable();
623        // After the first RESUME, opportunistically drain newly buffered events.
624        if resumed {
625            if let Some((ref nid, ch)) = selected {
626                let envelopes = rs.buffered_after(nid, ch, sent_through);
627                for env in envelopes {
628                    stream
629                        .write_all(&env.encode().map_err(RestreamerError::Frame)?)
630                        .await?;
631                    sent_through = sent_through.max(env.frame.seq);
632                }
633            }
634        }
635
636        // Wake on core input, on a newly buffered uplink frame (no 50 ms poll
637        // delay on the hot path), or on the fallback tick.
638        let read = tokio::select! {
639            r = stream.read(&mut buf[filled..]) => Ok(r),
640            _ = &mut woken => continue,
641            _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => Err(()),
642        };
643        match read {
644            Ok(Ok(0)) => break,
645            Ok(Ok(n)) => {
646                filled += n;
647                while filled > 0 {
648                    match Frame::decode(&buf[..filled]) {
649                        Ok((frame, consumed)) => {
650                            buf.copy_within(consumed..filled, 0);
651                            filled -= consumed;
652                            match frame.frame_type {
653                                FrameType::Open => {
654                                    let open =
655                                        frame.parse_open().map_err(RestreamerError::Frame)?;
656                                    selected = Some((open.node_id, frame.channel_id));
657                                    sent_through = 0;
658                                    resumed = false;
659                                }
660                                FrameType::Ack => {
661                                    if let Some((ref nid, ch)) = selected {
662                                        rs.ack(nid, ch, frame.seq);
663                                    }
664                                }
665                                FrameType::Resume => {
666                                    if let Some((ref nid, ch)) = selected {
667                                        let after = frame.seq;
668                                        let envelopes = rs.resume_with_uplink(nid, ch, after).await?;
669                                        for env in envelopes {
670                                            stream
671                                                .write_all(
672                                                    &env.encode().map_err(RestreamerError::Frame)?,
673                                                )
674                                                .await?;
675                                            sent_through = sent_through.max(env.frame.seq);
676                                        }
677                                        // Even with an empty replay, do not resend <= after.
678                                        sent_through = sent_through.max(after);
679                                        resumed = true;
680                                    }
681                                }
682                                FrameType::Data => {
683                                    // Downlink: opaque bytes for the selected node, relayed as-is.
684                                    if let Some((ref nid, ch)) = selected {
685                                        let down = Frame::data(ch, frame.seq, frame.payload)
686                                            .map_err(RestreamerError::Frame)?;
687                                        if let Err(err) = rs.peers().send_to(nid, down).await {
688                                            tracing::debug!(error = %err, "downlink dropped");
689                                        }
690                                    }
691                                }
692                                FrameType::Ping => {
693                                    let pong = Frame::pong(frame.seq);
694                                    stream
695                                        .write_all(
696                                            &pong.encode().map_err(RestreamerError::Frame)?,
697                                        )
698                                        .await?;
699                                }
700                                FrameType::Close | FrameType::GoAway => return Ok(()),
701                                _ => {}
702                            }
703                        }
704                        Err(FrameError::Truncated) => break,
705                        Err(err) => return Err(RestreamerError::Frame(err)),
706                    }
707                }
708            }
709            Ok(Err(e)) => return Err(e.into()),
710            Err(_) => {
711                // idle tick — loop to flush buffer
712            }
713        }
714    }
715    Ok(())
716}
717
718fn tokens_equal(a: &[u8], b: &[u8]) -> bool {
719    if a.len() != b.len() {
720        return false;
721    }
722    let mut diff = 0u8;
723    for (x, y) in a.iter().zip(b.iter()) {
724        diff |= x ^ y;
725    }
726    diff == 0
727}
728
729/// Core-side drain client: dials C2, authenticates, RESUMEs, ACKs.
730pub struct DrainClient {
731    addr: SocketAddr,
732    token: String,
733}
734
735impl DrainClient {
736    pub fn new(addr: SocketAddr, token: impl Into<String>) -> Self {
737        Self {
738            addr,
739            token: token.into(),
740        }
741    }
742
743    pub async fn connect(&self) -> Result<DrainSession, RestreamerError> {
744        let mut stream = TcpStream::connect(self.addr).await?;
745        let _ = stream.set_nodelay(true);
746        stream.write_all(self.token.as_bytes()).await?;
747        stream.write_all(b"\n").await?;
748        Ok(DrainSession { stream })
749    }
750}
751
752pub struct DrainSession {
753    stream: TcpStream,
754}
755
756impl DrainSession {
757    pub async fn open(&mut self, node_id: &str, channel_id: u32, kind: ChannelKind) -> Result<(), RestreamerError> {
758        let frame = Frame::open(
759            channel_id,
760            &OpenPayload {
761                channel_kind: kind,
762                node_id: node_id.to_owned(),
763            },
764        )
765        .map_err(RestreamerError::Frame)?;
766        self.stream
767            .write_all(&frame.encode().map_err(RestreamerError::Frame)?)
768            .await?;
769        Ok(())
770    }
771
772    pub async fn resume(&mut self, after: u64) -> Result<(), RestreamerError> {
773        let frame = Frame::resume(after);
774        self.stream
775            .write_all(&frame.encode().map_err(RestreamerError::Frame)?)
776            .await?;
777        Ok(())
778    }
779
780    pub async fn ack(&mut self, seq: u64) -> Result<(), RestreamerError> {
781        let frame = Frame::ack(seq);
782        self.stream
783            .write_all(&frame.encode().map_err(RestreamerError::Frame)?)
784            .await?;
785        Ok(())
786    }
787
788    pub async fn recv_envelope(
789        &mut self,
790        deadline: std::time::Duration,
791    ) -> Result<Option<DrainEnvelope>, RestreamerError> {
792        let mut buf = vec![0u8; 64 * 1024];
793        let n = match tokio::time::timeout(deadline, self.stream.read(&mut buf)).await {
794            Ok(Ok(0)) => return Ok(None),
795            Ok(Ok(n)) => n,
796            Ok(Err(e)) => return Err(e.into()),
797            Err(_) => return Ok(None),
798        };
799        let (env, _) = DrainEnvelope::decode(&buf[..n]).map_err(RestreamerError::Frame)?;
800        Ok(Some(env))
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use crate::restreamer::crypto::{core_open_inner, noise_session_seal, HqRecipientSecret};
808    use crate::restreamer::journal::MemoryJournal;
809    use hatchery_core::ChannelKind;
810    use std::time::Duration;
811
812    fn token() -> String {
813        "test-core-token-32bytes-minimum!".to_owned()
814    }
815
816    #[tokio::test]
817    async fn core_drains_and_acks_without_c2_push() {
818        let journal = Arc::new(MemoryJournal::new());
819        let rs = Restreamer::new(
820            super::super::buffer::BufferLimits {
821                max_events: 100,
822                max_bytes: 1_000_000,
823                max_age: Duration::from_secs(60),
824            },
825            journal,
826            token(),
827            None,
828        );
829        rs.open_channel("node-a", 1, ChannelKind::M4a);
830        assert!(matches!(
831            rs.admit_from_node("node-a", 1, b"cipher-1".to_vec()).unwrap(),
832            EnqueueOutcome::Accepted { .. }
833        ));
834        assert!(matches!(
835            rs.admit_from_node("node-a", 1, b"cipher-2".to_vec()).unwrap(),
836            EnqueueOutcome::Accepted { .. }
837        ));
838        let batch = rs.buffered_after("node-a", 1, 0);
839        assert_eq!(batch.len(), 2);
840        assert_eq!(batch[0].frame.payload, b"cipher-1");
841        rs.ack("node-a", 1, 1);
842        let batch = rs.buffered_after("node-a", 1, 0);
843        assert_eq!(batch.len(), 1);
844        assert_eq!(batch[0].frame.seq, 2);
845        assert!(!rs.ever_pushed_to_core());
846    }
847
848    #[tokio::test]
849    async fn live_drain_session_resume_and_ack() {
850        let journal = Arc::new(MemoryJournal::new());
851        // Pre-populate journal for gap recovery.
852        for seq in 1..=5u64 {
853            journal.append("node-a", 1, seq, format!("j{seq}").into_bytes());
854        }
855        let rs = Restreamer::new(
856            super::super::buffer::BufferLimits {
857                max_events: 2,
858                max_bytes: 1024,
859                max_age: Duration::from_secs(60),
860            },
861            journal.clone(),
862            token(),
863            None,
864        );
865        // Fill buffer with seq 4,5 only (simulating 1..3 already acked/expired).
866        // Manually: enqueue three then ack to leave two? limits max_events=2 so third backpressures.
867        rs.admit_from_node("node-a", 1, b"b1".to_vec()).unwrap();
868        rs.admit_from_node("node-a", 1, b"b2".to_vec()).unwrap();
869        // ack both so buffer empty but next_seq advances; then we cannot get old seqs from buffer.
870        rs.ack("node-a", 1, 2);
871        // Produce seq 3,4 in buffer
872        rs.admit_from_node("node-a", 1, b"b3".to_vec()).unwrap();
873        rs.admit_from_node("node-a", 1, b"b4".to_vec()).unwrap();
874
875        // RESUME after 0: gap for 1..2 from journal, then 3..4 from buffer.
876        // But journal has 1..5 with different payloads; buffer has its own seq 3,4 with b3/b4.
877        // For a clean gap test: ack everything, empty buffer, resume after 0 with journal only.
878        let journal2 = Arc::new(MemoryJournal::new());
879        for seq in 1..=4u64 {
880            journal2.append("node-b", 1, seq, format!("evt-{seq}").into_bytes());
881        }
882        let rs2 = Restreamer::new(
883            super::super::buffer::BufferLimits {
884                max_events: 10,
885                max_bytes: 10_000,
886                max_age: Duration::from_secs(60),
887            },
888            journal2,
889            token(),
890            None,
891        );
892        // Advance next_seq without retaining: enqueue + ack
893        for i in 1..=4u8 {
894            rs2.admit_from_node("node-b", 1, vec![i]).unwrap();
895        }
896        rs2.ack("node-b", 1, 4);
897        assert!(rs2.buffered_after("node-b", 1, 0).is_empty());
898        let recovered = rs2.resume("node-b", 1, 0).unwrap();
899        assert_eq!(recovered.len(), 4);
900        assert_eq!(recovered[0].frame.payload, b"evt-1");
901        assert_eq!(recovered[3].frame.seq, 4);
902        assert!(!rs2.ever_pushed_to_core());
903    }
904
905    #[tokio::test]
906    async fn tcp_drain_end_to_end() {
907        let journal = Arc::new(MemoryJournal::new());
908        let rs = Restreamer::new(
909            super::super::buffer::BufferLimits::default(),
910            journal,
911            token(),
912            None,
913        );
914        let node_l = TcpListener::bind("127.0.0.1:0").await.unwrap();
915        let drain_l = TcpListener::bind("127.0.0.1:0").await.unwrap();
916        let node_addr = node_l.local_addr().unwrap();
917        let drain_addr = drain_l.local_addr().unwrap();
918        drop(node_l);
919        drop(drain_l);
920        let (shutdown_tx, shutdown_rx) = watch::channel(false);
921        let cfg = RestreamerConfig {
922            node_listen: node_addr,
923            drain_listen: drain_addr,
924            buffer: super::super::buffer::BufferLimits::default(),
925            core_token: token(),
926        };
927        let serve_rs = rs.clone();
928        let handle = tokio::spawn(async move { serve(cfg, serve_rs, shutdown_rx).await });
929
930        // Give the server a moment to bind.
931        tokio::time::sleep(Duration::from_millis(20)).await;
932
933        // Node uplink
934        let mut node = TcpStream::connect(node_addr).await.unwrap();
935        let open = Frame::open(
936            1,
937            &OpenPayload {
938                channel_kind: ChannelKind::G4aEvents,
939                node_id: "node-a".into(),
940            },
941        )
942        .unwrap();
943        node.write_all(&open.encode().unwrap()).await.unwrap();
944        // read WINDOW
945        let mut rb = [0u8; 64];
946        let _ = node.read(&mut rb).await.unwrap();
947        let data = Frame::data(1, 0, b"opaque-event".to_vec()).unwrap();
948        // Note: node-assigned seq on wire is ignored; C2 assigns its own.
949        node.write_all(&data.encode().unwrap()).await.unwrap();
950        let _ = node.read(&mut rb).await.unwrap();
951
952        // Core drain
953        let client = DrainClient::new(drain_addr, token());
954        let mut session = client.connect().await.unwrap();
955        session
956            .open("node-a", 1, ChannelKind::G4aEvents)
957            .await
958            .unwrap();
959        session.resume(0).await.unwrap();
960        let env = session
961            .recv_envelope(Duration::from_secs(2))
962            .await
963            .unwrap()
964            .expect("envelope");
965        assert_eq!(env.node_id, "node-a");
966        assert_eq!(env.frame.payload, b"opaque-event");
967        session.ack(env.frame.seq).await.unwrap();
968
969        let _ = shutdown_tx.send(true);
970        let _ = handle.await;
971        assert!(!rs.ever_pushed_to_core());
972    }
973
974    #[test]
975    fn nested_crypto_on_admit_path() {
976        let hq = HqRecipientSecret::from_seed([11u8; 32]);
977        let keys = RestreamerKeys::from_seeds([13u8; 32], hq.public_bytes().0);
978        let journal = Arc::new(MemoryJournal::new());
979        let rs = Restreamer::new(
980            super::super::buffer::BufferLimits::default(),
981            journal,
982            token(),
983            Some(keys.clone()),
984        );
985        let sealed = noise_session_seal(&keys, &hq, b"visible-only-to-core").unwrap();
986        rs.admit_from_node("node-a", 1, sealed.outer).unwrap();
987        let env = &rs.buffered_after("node-a", 1, 0)[0];
988        assert!(!env
989            .frame
990            .payload
991            .windows(b"visible-only-to-core".len())
992            .any(|w| w == b"visible-only-to-core"));
993        let mut hq_tx = sealed.hq_transport;
994        let plain = core_open_inner(&mut hq_tx, &env.frame.payload).unwrap();
995        assert_eq!(plain, b"visible-only-to-core");
996    }
997
998    #[tokio::test]
999    async fn uplink_journal_req_served_from_file_journal() {
1000        use crate::restreamer::FileJournal;
1001        use hatchery_core::FrameType;
1002
1003        let dir = std::env::temp_dir().join(format!("upj-{}", std::process::id()));
1004        let _ = std::fs::remove_dir_all(&dir);
1005        let fj = FileJournal::open(dir.join("n.journal")).unwrap();
1006        for i in 0..5u8 {
1007            fj.append(1, &[i]).unwrap();
1008        }
1009        let journal = Arc::new(MemoryJournal::new());
1010        let rs = Restreamer::new(
1011            super::super::buffer::BufferLimits::default(),
1012            journal,
1013            token(),
1014            None,
1015        );
1016        let node_l = TcpListener::bind("127.0.0.1:0").await.unwrap();
1017        let drain_l = TcpListener::bind("127.0.0.1:0").await.unwrap();
1018        let node_addr = node_l.local_addr().unwrap();
1019        let drain_addr = drain_l.local_addr().unwrap();
1020        drop(node_l);
1021        drop(drain_l);
1022        let (shutdown_tx, shutdown_rx) = watch::channel(false);
1023        let cfg = RestreamerConfig {
1024            node_listen: node_addr,
1025            drain_listen: drain_addr,
1026            buffer: super::super::buffer::BufferLimits::default(),
1027            core_token: token(),
1028        };
1029        let serve_rs = rs.clone();
1030        let handle = tokio::spawn(async move { serve(cfg, serve_rs, shutdown_rx).await });
1031        tokio::time::sleep(Duration::from_millis(20)).await;
1032
1033        let fj_node = fj.clone();
1034        let node_task = tokio::spawn(async move {
1035            let mut node = TcpStream::connect(node_addr).await.unwrap();
1036            let open = Frame::open(
1037                1,
1038                &OpenPayload {
1039                    channel_kind: ChannelKind::G4aEvents,
1040                    node_id: "node-a".into(),
1041                },
1042            )
1043            .unwrap();
1044            node.write_all(&open.encode().unwrap()).await.unwrap();
1045            let mut rb = vec![0u8; 64 * 1024];
1046            let mut filled = 0usize;
1047            loop {
1048                let n = node.read(&mut rb[filled..]).await.unwrap();
1049                if n == 0 {
1050                    break;
1051                }
1052                filled += n;
1053                while filled > 0 {
1054                    match Frame::decode(&rb[..filled]) {
1055                        Ok((frame, consumed)) => {
1056                            rb.copy_within(consumed..filled, 0);
1057                            filled -= consumed;
1058                            if frame.frame_type == FrameType::JournalReq {
1059                                let limit = frame.journal_req_limit().unwrap_or(64) as usize;
1060                                let body = fj_node
1061                                    .reply_body(frame.channel_id, frame.seq, limit)
1062                                    .unwrap();
1063                                let reply = Frame::journal_reply(frame.channel_id, &body).unwrap();
1064                                node.write_all(&reply.encode().unwrap()).await.unwrap();
1065                            }
1066                        }
1067                        Err(FrameError::Truncated) => break,
1068                        Err(e) => panic!("{e}"),
1069                    }
1070                }
1071            }
1072        });
1073
1074        for _ in 0..50 {
1075            if rs.peers().is_registered("node-a") {
1076                break;
1077            }
1078            tokio::time::sleep(Duration::from_millis(10)).await;
1079        }
1080        assert!(rs.peers().is_registered("node-a"));
1081        let body = rs
1082            .peers()
1083            .request_journal("node-a", 1, 2, 10, Duration::from_secs(2))
1084            .await
1085            .unwrap();
1086        assert_eq!(
1087            body.entries.iter().map(|e| e.seq).collect::<Vec<_>>(),
1088            vec![3, 4, 5]
1089        );
1090        assert_eq!(body.entries[0].payload, &[2]);
1091
1092        let _ = shutdown_tx.send(true);
1093        node_task.abort();
1094        let _ = handle.await;
1095        let _ = std::fs::remove_dir_all(&dir);
1096    }
1097}