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