Skip to main content

hatchery_node/
restream_session.rs

1//! Node end of a live restreamer session (A2 / A3).
2//!
3//! The node dials C2's uplink, OPENs a channel, then runs the inner Noise_NK
4//! handshake toward the relayed recipient key as ordinary DATA frames:
5//! msg1 goes up through C2 into the core drain, msg2 comes back down the same
6//! stream. After that every request and reply is Noise transport ciphertext,
7//! wrapped in C2's outer layer on the way up. C2 only ever sees opaque bytes,
8//! and the node never learns what sits behind C2.
9//!
10//! A node may open one TCP per role (g4a/PTY bridge, dedicated m4a). C2 routes
11//! downlink by `(node_id, channel_id)` to the TCP that OPENed that channel.
12//! On a shared TCP, a single FIFO send queue would let browser frames or a stuck
13//! mail channel wedge PTY control (head-of-line blocking). Outbound traffic is
14//! therefore queued per-channel with priority scheduling, browser frames are
15//! dropped *before* Noise encrypt under pressure (never dequeue already-encrypted
16//! DATA — that desyncs nonces and whitescreens HQ), and a write timeout tears
17//! the socket down so the bridge reconnects.
18
19use crate::uplink_journal::UplinkJournal;
20use hatchery_core::inner::{decode_inner, encode_inner, InnerKind};
21use hatchery_core::nested::{node_seal_outer, NoiseInitiator, NoiseTransport, PublicKeyBytes};
22use hatchery_core::{encode_udp_data, grow_read_buf, ChannelKind, Frame, FrameError, FrameType, OpenPayload};
23use serde_json::{json, Value};
24use std::collections::{BTreeMap, VecDeque};
25use std::net::SocketAddr;
26use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29use tokio::io::{AsyncReadExt, AsyncWriteExt};
30use tokio::net::TcpStream;
31use tokio::sync::{mpsc, Notify};
32use tokio::task::JoinHandle;
33
34/// App-level ping on an idle node→C2 uplink (C2 answers Pong).
35const UPLINK_PING_EVERY: Duration = Duration::from_secs(10);
36/// No inbound bytes (pong or data) for this long => path is dead; reconnect.
37const UPLINK_DEAD_AFTER: Duration = Duration::from_secs(35);
38
39/// Keys C2 hands a node at enrollment: its own outer key and the upstream recipient key.
40#[derive(Clone, Copy, Debug)]
41pub struct UpstreamKeys {
42    pub c2_public: PublicKeyBytes,
43    pub recipient_public: PublicKeyBytes,
44}
45
46/// Outbound priority on the shared uplink. Lower ordinal = sent first.
47#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
48pub enum SendPrio {
49    /// OPEN, Noise handshake, journal replies, g4a request/response (incl. PTY input path).
50    Control = 0,
51    /// g4a terminal / event frames.
52    Pty = 1,
53    /// m4a mail/task calls.
54    Mail = 2,
55    /// dig2browser frames — droppable / coalesced under pressure.
56    Browser = 3,
57}
58
59const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
60
61fn udp_pty_enabled() -> bool {
62    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
63    *ON.get_or_init(|| {
64        // Default on (0.2.53): only sessions that opted in via `set_udp` use it.
65        !matches!(
66            std::env::var("HATCHERY_PTY_UDP").ok().as_deref(),
67            Some("0") | Some("false") | Some("FALSE") | Some("no")
68        )
69    })
70}
71
72const CHAN_CAP_CONTROL: usize = 64;
73const CHAN_CAP_PTY: usize = 128;
74const CHAN_CAP_MAIL: usize = 16;
75const CHAN_CAP_BROWSER: usize = 8;
76
77struct Queued {
78    prio: SendPrio,
79    frame: Frame,
80}
81
82struct ChanQ {
83    q: VecDeque<Queued>,
84    /// Remaining WINDOW credit for DATA frames; None until the first WINDOW.
85    credit: Option<u32>,
86}
87
88impl ChanQ {
89    fn new() -> Self {
90        Self { q: VecDeque::new(), credit: None }
91    }
92
93    fn cap_for(prio: SendPrio) -> usize {
94        match prio {
95            SendPrio::Control => CHAN_CAP_CONTROL,
96            SendPrio::Pty => CHAN_CAP_PTY,
97            SendPrio::Mail => CHAN_CAP_MAIL,
98            SendPrio::Browser => CHAN_CAP_BROWSER,
99        }
100    }
101}
102
103struct OutState {
104    chans: BTreeMap<u32, ChanQ>,
105}
106
107/// One TCP uplink to C2 carrying any number of channels.
108/// Optional UDP socket on the same peer for PTY bulk (SendPrio::Pty DATA).
109pub struct NodeUplink {
110    enqueue: mpsc::UnboundedSender<(u32, SendPrio, Frame)>,
111    /// Shared with the ingress task so we can refuse Browser encrypt when congested.
112    out_state: Arc<Mutex<OutState>>,
113    routes: Arc<Mutex<BTreeMap<u32, mpsc::UnboundedSender<Vec<u8>>>>>,
114    /// Last WINDOW credit per channel (updated by the reader).
115    credits: Arc<Mutex<BTreeMap<u32, u32>>>,
116    credit_pulse: Arc<Notify>,
117    journal: Option<Arc<UplinkJournal>>,
118    alive: Arc<AtomicBool>,
119    dead: Arc<Notify>,
120    tasks: Vec<JoinHandle<()>>,
121    /// Peer address for the UDP data plane (same as TCP uplink).
122    udp_peer: Option<std::net::SocketAddr>,
123    /// Fallback OS UDP socket when userspace WG is not carrying the peer
124    /// (kernel WireGuard / no-wg builds). Prefer kit_wireguard::udp_send.
125    udp: Option<Arc<std::net::UdpSocket>>,
126    udp_node_id: Arc<Mutex<Option<String>>>,
127    udp_sent: AtomicU64,
128}
129
130impl Drop for NodeUplink {
131    fn drop(&mut self) {
132        self.alive.store(false, Ordering::SeqCst);
133        for t in &self.tasks {
134            t.abort();
135        }
136    }
137}
138
139fn send_udp_datagram(
140    peer: std::net::SocketAddr,
141    dg: &[u8],
142    os: Option<&std::net::UdpSocket>,
143) -> Result<(), String> {
144    #[cfg(feature = "wireguard")]
145    {
146        match gate4agent_node_core::kit_wireguard::udp_send(peer, dg) {
147            Ok(()) => return Ok(()),
148            Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {
149                return Err(format!("udp pty too large for WG MTU: {e}"));
150            }
151            Err(e) => {
152                // Fall through to OS socket (kernel WG).
153                tracing::debug!(error = %e, "userspace udp_send failed; trying OS");
154            }
155        }
156    }
157    let Some(udp) = os else {
158        return Err("udp data plane unavailable".into());
159    };
160    udp.send(dg).map_err(|e| format!("udp pty send: {e}"))?;
161    Ok(())
162}
163
164/// Dial the C2 uplink. With the `wireguard` feature, an address served by an
165/// in-process WireGuard session (Windows) is carried inside it; otherwise this
166/// is a plain TCP connect.
167fn dial_uplink(addr: SocketAddr) -> std::io::Result<std::net::TcpStream> {
168    #[cfg(feature = "wireguard")]
169    {
170        gate4agent_node_core::kit_wireguard::tcp_connect(addr)
171    }
172    #[cfg(not(feature = "wireguard"))]
173    {
174        std::net::TcpStream::connect(addr)
175    }
176}
177
178impl NodeUplink {
179    pub async fn connect(uplink: SocketAddr, journal: Option<Arc<UplinkJournal>>) -> Result<Arc<Self>, String> {
180        let dial = uplink;
181        let started = std::time::Instant::now();
182        crate::node_log::info(
183            "uplink_connect_attempt",
184            &[("addr_port", dial.port().to_string())],
185        );
186        let std_stream = match tokio::task::spawn_blocking(move || dial_uplink(dial)).await {
187            Ok(Ok(s)) => {
188                crate::node_log::info(
189                    "uplink_connect_ok",
190                    &[
191                        ("addr_port", dial.port().to_string()),
192                        ("ms", started.elapsed().as_millis().to_string()),
193                    ],
194                );
195                s
196            }
197            Ok(Err(e)) => {
198                crate::node_log::warn(
199                    "uplink_connect_fail",
200                    &[
201                        ("addr_port", dial.port().to_string()),
202                        ("error", e.to_string()),
203                        ("ms", started.elapsed().as_millis().to_string()),
204                    ],
205                );
206                return Err(e.to_string());
207            }
208            Err(e) => {
209                crate::node_log::warn(
210                    "uplink_connect_fail",
211                    &[
212                        ("addr_port", dial.port().to_string()),
213                        ("error", e.to_string()),
214                        ("ms", started.elapsed().as_millis().to_string()),
215                    ],
216                );
217                return Err(e.to_string());
218            }
219        };
220        let _ = std_stream.set_nodelay(true);
221        // OS keepalive backs up the app-level ping. On the in-process
222        // WireGuard path this socket is loopback and the inner tunnel socket
223        // carries its own keepalive (gate4agent-node-core).
224        if let Err(error) = crate::server::enable_tcp_keepalive(&std_stream) {
225            tracing::warn!(cause = %error, "could not enable TCP keepalive on the uplink");
226        }
227        // current_thread runtime. A blocking socket here stalls the only
228        // worker inside the first read, so the OPEN writer and the handshake
229        // timeout never run. Windows does not flip this in `from_std`.
230        std_stream.set_nonblocking(true).map_err(|e| e.to_string())?;
231        let stream = TcpStream::from_std(std_stream).map_err(|e| e.to_string())?;
232        // UDP data plane (same peer): PTY bulk. Prefer userspace-WG udp_send
233        // (plain OS UDP never enters the in-process tunnel). OS socket is a
234        // fallback for kernel WireGuard / non-wg builds.
235        let udp_peer = Some(dial);
236        let udp = match std::net::UdpSocket::bind("0.0.0.0:0") {
237            Ok(sock) => match sock.connect(dial) {
238                Ok(()) => {
239                    let _ = sock.set_nonblocking(true);
240                    Some(Arc::new(sock))
241                }
242                Err(_) => None,
243            },
244            Err(_) => None,
245        };
246        tracing::info!(%dial, via = "wireguard-or-os", "uplink UDP data plane ready");
247        let udp_node_id = Arc::new(Mutex::new(None));
248        let (mut rd, mut wr) = stream.into_split();
249        let (enq_tx, mut enq_rx) = mpsc::unbounded_channel::<(u32, SendPrio, Frame)>();
250        let routes: Arc<Mutex<BTreeMap<u32, mpsc::UnboundedSender<Vec<u8>>>>> = Arc::default();
251        let credits: Arc<Mutex<BTreeMap<u32, u32>>> = Arc::default();
252        let credit_pulse = Arc::new(Notify::new());
253        let alive = Arc::new(AtomicBool::new(true));
254        let dead = Arc::new(Notify::new());
255        let wake = Arc::new(Notify::new());
256
257        let out_state = Arc::new(Mutex::new(OutState { chans: BTreeMap::new() }));
258        // Last inbound byte (ms since `born`). The keepalive below pings C2 on
259        // an idle uplink so NAT/WG/conntrack state stays warm and a half-open
260        // path is detected in seconds, not discovered by the next HQ spawn
261        // (owner spawn after ~28 min idle sat ~23 s before reaching the node).
262        let born = std::time::Instant::now();
263        let last_rx = Arc::new(AtomicU64::new(0));
264
265        // Ingress into per-channel queues (never blocks the producer on TCP).
266        let state_in = Arc::clone(&out_state);
267        let wake_in = Arc::clone(&wake);
268        let alive_in = Arc::clone(&alive);
269        let ingress = tokio::spawn(async move {
270            while let Some((ch, prio, frame)) = enq_rx.recv().await {
271                if !alive_in.load(Ordering::SeqCst) {
272                    break;
273                }
274                let mut st = state_in.lock().expect("out");
275                let q = st.chans.entry(ch).or_insert_with(ChanQ::new);
276                let cap = ChanQ::cap_for(prio);
277                // Noise transport nonces are assigned at encrypt time (before enqueue).
278                // Never dequeue ciphertexts. Pre-encrypt enqueue_ok should prevent this;
279                // if a race still delivers an over-cap frame, tear the uplink down.
280                if q.q.len() >= cap {
281                    tracing::error!(?prio, cap, qlen = q.q.len(), "ciphertext dropped after encrypt (nonce gap); reconnecting uplink");
282                    drop(st);
283                    alive_in.store(false, Ordering::SeqCst);
284                    break;
285                }
286                if prio == SendPrio::Browser
287                    && q.q.iter().any(|x| x.prio == SendPrio::Browser)
288                {
289                    // Pre-encrypt gate should prevent this; a race burns a nonce → desync.
290                    tracing::error!("browser ciphertext dropped after encrypt (nonce gap); reconnecting uplink");
291                    drop(st);
292                    alive_in.store(false, Ordering::SeqCst);
293                    break;
294                }
295                q.q.push_back(Queued { prio, frame });
296                drop(st);
297                wake_in.notify_one();
298            }
299        });
300
301        // Fair/priority writer: always pick the globally highest-priority waiting frame
302        // whose channel has credit (DATA) or needs no credit (OPEN/control non-DATA).
303        let state_wr = Arc::clone(&out_state);
304        let wake_wr = Arc::clone(&wake);
305        let alive_wr = Arc::clone(&alive);
306        let dead_wr = Arc::clone(&dead);
307        let routes_wr = Arc::clone(&routes);
308        let writer = tokio::spawn(async move {
309            loop {
310                if !alive_wr.load(Ordering::SeqCst) {
311                    break;
312                }
313                let next = {
314                    let mut st = state_wr.lock().expect("out");
315                    pick_next(&mut st)
316                };
317                let Some((ch, is_data, frame)) = next else {
318                    wake_wr.notified().await;
319                    continue;
320                };
321                let Ok(bytes) = frame.encode() else {
322                    kill(&alive_wr, &dead_wr, &routes_wr);
323                    break;
324                };
325                match tokio::time::timeout(WRITE_TIMEOUT, wr.write_all(&bytes)).await {
326                    Ok(Ok(())) => {
327                        if is_data {
328                            let mut st = state_wr.lock().expect("out");
329                            if let Some(q) = st.chans.get_mut(&ch) {
330                                if let Some(c) = q.credit.as_mut() {
331                                    *c = c.saturating_sub(1);
332                                }
333                            }
334                        }
335                    }
336                    Ok(Err(_)) | Err(_) => {
337                        tracing::warn!(%ch, "uplink write timed out or failed; reconnecting");
338                        kill(&alive_wr, &dead_wr, &routes_wr);
339                        break;
340                    }
341                }
342            }
343        });
344
345        let enq_reader = enq_tx.clone();
346        let reader_journal = journal.clone();
347        let reader_routes = Arc::clone(&routes);
348        let alive_rd = Arc::clone(&alive);
349        let dead_rd = Arc::clone(&dead);
350        let state_rd = Arc::clone(&out_state);
351        let wake_rd = Arc::clone(&wake);
352        let credits_rd = Arc::clone(&credits);
353        let credit_pulse_rd = Arc::clone(&credit_pulse);
354        let last_rx_rd = Arc::clone(&last_rx);
355        let reader = tokio::spawn(async move {
356            let mut buf = vec![0u8; 128 * 1024];
357            let mut filled = 0usize;
358            loop {
359                let Ok(n) = rd.read(&mut buf[filled..]).await else { break };
360                if n == 0 {
361                    break;
362                }
363                filled += n;
364                last_rx_rd.store(born.elapsed().as_millis() as u64, Ordering::SeqCst);
365                loop {
366                    match Frame::decode(&buf[..filled]) {
367                        Ok((frame, used)) => {
368                            buf.copy_within(used..filled, 0);
369                            filled -= used;
370                            match frame.frame_type {
371                                FrameType::Data => {
372                                    let tx = reader_routes.lock().expect("routes").get(&frame.channel_id).cloned();
373                                    if let Some(tx) = tx {
374                                        let _ = tx.send(frame.payload);
375                                    }
376                                }
377                                FrameType::Window => {
378                                    let credit = frame.seq.min(u64::from(u32::MAX)) as u32;
379                                    {
380                                        let mut st = state_rd.lock().expect("out");
381                                        st.chans.entry(frame.channel_id).or_insert_with(ChanQ::new).credit = Some(credit);
382                                    }
383                                    credits_rd.lock().expect("credits").insert(frame.channel_id, credit);
384                                    credit_pulse_rd.notify_waiters();
385                                    wake_rd.notify_one();
386                                }
387                                FrameType::JournalReq => {
388                                    let Some(j) = reader_journal.as_ref() else { continue };
389                                    let limit = frame.journal_req_limit().unwrap_or(64) as usize;
390                                    if let Ok(body) = j.journal().reply_body(frame.channel_id, frame.seq, limit) {
391                                        if let Ok(reply) = Frame::journal_reply(frame.channel_id, &body) {
392                                            let _ = enq_reader.send((frame.channel_id, SendPrio::Control, reply));
393                                        }
394                                    }
395                                }
396                                FrameType::GoAway | FrameType::Close => {
397                                    kill(&alive_rd, &dead_rd, &reader_routes);
398                                    return;
399                                }
400                                _ => {}
401                            }
402                        }
403                        Err(FrameError::Truncated) => break,
404                        Err(_) => {
405                            kill(&alive_rd, &dead_rd, &reader_routes);
406                            return;
407                        }
408                    }
409                }
410                if filled == buf.len() {
411                    // Same grow path as C2: do not tear the bridge when a
412                    // single frame exceeds the initial 128 KiB read buf.
413                    if grow_read_buf(&mut buf, filled).is_err() {
414                        break;
415                    }
416                }
417            }
418            kill(&alive_rd, &dead_rd, &reader_routes);
419        });
420
421        let ka_tx = enq_tx.clone();
422        let alive_ka = Arc::clone(&alive);
423        let dead_ka = Arc::clone(&dead);
424        let routes_ka = Arc::clone(&routes);
425        let keepalive = tokio::spawn(async move {
426            let mut nonce = 0u64;
427            let mut tick = tokio::time::interval(UPLINK_PING_EVERY);
428            tick.tick().await;
429            loop {
430                tick.tick().await;
431                if !alive_ka.load(Ordering::SeqCst) {
432                    break;
433                }
434                let now = born.elapsed().as_millis() as u64;
435                let idle = now.saturating_sub(last_rx.load(Ordering::SeqCst));
436                if idle > UPLINK_DEAD_AFTER.as_millis() as u64 {
437                    tracing::warn!(idle_ms = idle, "uplink silent past keepalive deadline; reconnecting");
438                    kill(&alive_ka, &dead_ka, &routes_ka);
439                    break;
440                }
441                nonce = nonce.wrapping_add(1);
442                if ka_tx.send((0, SendPrio::Control, Frame::ping(nonce))).is_err() {
443                    break;
444                }
445            }
446        });
447
448        Ok(Arc::new(Self {
449            enqueue: enq_tx,
450            out_state: Arc::clone(&out_state),
451            routes,
452            credits,
453            credit_pulse,
454            journal,
455            alive,
456            dead,
457            tasks: vec![ingress, writer, reader, keepalive],
458            udp_peer,
459            udp,
460            udp_node_id,
461            udp_sent: AtomicU64::new(0),
462        }))
463    }
464
465    /// Datagrams sent on the UDP data plane by this uplink.
466    pub fn udp_sent(&self) -> u64 {
467        self.udp_sent.load(Ordering::Relaxed)
468    }
469
470    pub fn is_alive(&self) -> bool {
471        self.alive.load(Ordering::SeqCst)
472    }
473
474    /// Wait until the uplink is marked dead (write timeout / peer close).
475    pub async fn closed(&self) {
476        if !self.is_alive() {
477            return;
478        }
479        self.dead.notified().await;
480    }
481
482    /// True if a new ciphertext can be enqueued without dropping anything.
483    /// Call *before* Noise encrypt so a refused frame does not burn a nonce.
484    fn enqueue_ok(&self, channel_id: u32, prio: SendPrio) -> bool {
485        let st = self.out_state.lock().expect("out");
486        let Some(q) = st.chans.get(&channel_id) else {
487            return true;
488        };
489        if q.q.len() >= ChanQ::cap_for(prio) {
490            return false;
491        }
492        if prio == SendPrio::Browser && q.q.iter().any(|x| x.prio == SendPrio::Browser) {
493            return false;
494        }
495        true
496    }
497
498    fn push(&self, channel_id: u32, prio: SendPrio, frame: Frame) -> Result<(), String> {
499        if !self.is_alive() {
500            return Err("uplink closed".into());
501        }
502        self.enqueue
503            .send((channel_id, prio, frame))
504            .map_err(|_| "uplink closed".to_owned())
505    }
506
507    /// OPEN `channel_id`, wait for WINDOW credit, then complete Noise_NK.
508    pub async fn open(
509        self: &Arc<Self>,
510        node_id: &str,
511        channel_id: u32,
512        kind: ChannelKind,
513        keys: UpstreamKeys,
514        handshake_timeout: Duration,
515    ) -> Result<NodeSession, String> {
516        let (down_tx, mut down_rx) = mpsc::unbounded_channel::<Vec<u8>>();
517        self.routes.lock().expect("routes").insert(channel_id, down_tx);
518        self.credits.lock().expect("credits").remove(&channel_id);
519        {
520            let mut g = self.udp_node_id.lock().expect("udp_node_id");
521            if g.is_none() {
522                *g = Some(node_id.to_owned());
523            }
524        }
525        let open = Frame::open(channel_id, &OpenPayload { channel_kind: kind, node_id: node_id.to_owned() })
526            .map_err(|e| e.to_string())?;
527        self.push(channel_id, SendPrio::Control, open)?;
528
529        // Wait until C2 grants WINDOW credit after OPEN (avoids sending Handshake1
530        // into a stream that still has credit 0 from a previous session).
531        tokio::time::timeout(handshake_timeout, async {
532            loop {
533                if !self.is_alive() {
534                    return Err("uplink closed".to_owned());
535                }
536                if self.credits.lock().expect("credits").get(&channel_id).copied().unwrap_or(0) > 0 {
537                    return Ok(());
538                }
539                tokio::select! {
540                    _ = self.credit_pulse.notified() => {}
541                    _ = self.closed() => return Err("uplink closed".to_owned()),
542                }
543            }
544        })
545        .await
546        .map_err(|_| "window credit timeout after OPEN".to_owned())??;
547
548        let mut initiator = NoiseInitiator::new(keys.recipient_public).map_err(|e| e.to_string())?;
549        let msg1 = initiator.write_message1().map_err(|e| e.to_string())?;
550        self.send_inner(channel_id, keys, SendPrio::Control, &encode_inner(InnerKind::Handshake1, &msg1), false)?;
551
552        let msg2 = tokio::time::timeout(handshake_timeout, async {
553            loop {
554                let Some(bytes) = down_rx.recv().await else { return Err("uplink closed".to_owned()) };
555                if let Some((InnerKind::Handshake2, body)) = decode_inner(&bytes) {
556                    return Ok(body.to_vec());
557                }
558            }
559        })
560        .await
561        .map_err(|_| "handshake timeout".to_owned())??;
562        let transport = initiator.read_message2(&msg2).map_err(|e| e.to_string())?;
563        Ok(NodeSession {
564            channel_id,
565            keys,
566            default_prio: match kind {
567                ChannelKind::M4a => SendPrio::Mail,
568                ChannelKind::G4aControl | ChannelKind::G4aPty | ChannelKind::G4aEvents => SendPrio::Pty,
569                ChannelKind::Vault | ChannelKind::Observation => SendPrio::Mail,
570            },
571            down: down_rx,
572            transport,
573            journal: self.journal.clone(),
574            uplink: Arc::clone(self),
575            udp: false,
576        })
577    }
578
579    fn send_inner(&self, channel_id: u32, keys: UpstreamKeys, prio: SendPrio, inner: &[u8], udp_on: bool) -> Result<(), String> {
580        if let Some(j) = &self.journal {
581            j.append(channel_id, inner)?;
582        }
583        let sealed = node_seal_outer(keys.c2_public, inner).map_err(|e| e.to_string())?;
584        let frame = Frame::data(channel_id, 0, sealed).map_err(|e| e.to_string())?;
585        // PTY bulk over UDP (opt-in): same Noise ciphertext as TCP would carry, so
586        // HATCHERY_PTY_UDP=1 must send *all* ch3 DATA via UDP (no mix) to keep nonces
587        // ordered. Default stays TCP on ch3 — still isolates WINDOW credit from control.
588        // Only a session that opted in (dedicated ch3) uses UDP: one channel never mixes paths.
589        if udp_on && prio == SendPrio::Pty && udp_pty_enabled() {
590            if let (Some(peer), Some(nid)) = (
591                self.udp_peer,
592                self.udp_node_id.lock().expect("udp_node_id").clone(),
593            ) {
594                match encode_udp_data(&nid, &frame) {
595                    Ok(dg) => {
596                        // No TCP mix: would break Noise nonces on ch3.
597                        send_udp_datagram(peer, &dg, self.udp.as_deref())?;
598                        self.udp_sent.fetch_add(1, Ordering::Relaxed);
599                        return Ok(());
600                    }
601                    Err(e) => return Err(format!("udp encode: {e}")),
602                }
603            }
604        }
605        self.push(channel_id, prio, frame)
606    }
607}
608
609fn kill(alive: &AtomicBool, dead: &Notify, routes: &Mutex<BTreeMap<u32, mpsc::UnboundedSender<Vec<u8>>>>) {
610    if alive.swap(false, Ordering::SeqCst) {
611        routes.lock().expect("routes").clear();
612        dead.notify_waiters();
613    }
614}
615
616/// Pick the highest-priority queued frame that may be sent now.
617fn pick_next(st: &mut OutState) -> Option<(u32, bool, Frame)> {
618    // Candidates: (prio, channel, index). DATA frames of one channel are Noise ciphertexts
619    // encrypted in queue order, so only the channel's first DATA frame may go next
620    // (overtaking breaks the receiver's nonce sequence); non-DATA frames may go any time.
621    let mut best: Option<(SendPrio, u32, usize)> = None;
622    for (&ch, cq) in &st.chans {
623        let first_data = cq.q.iter().position(|it| matches!(it.frame.frame_type, FrameType::Data));
624        for (i, item) in cq.q.iter().enumerate() {
625            let needs_credit = matches!(item.frame.frame_type, FrameType::Data);
626            if needs_credit && Some(i) != first_data {
627                continue;
628            }
629            if needs_credit {
630                match cq.credit {
631                    Some(0) => continue,
632                    None => {
633                        // No WINDOW yet: allow Control DATA (handshake) so OPEN→Handshake1 works;
634                        // hold back bulk traffic.
635                        if item.prio > SendPrio::Control {
636                            continue;
637                        }
638                    }
639                    Some(_) => {}
640                }
641            }
642            let cand = (item.prio, ch, i);
643            if best.is_none_or(|b| cand.0 < b.0 || (cand.0 == b.0 && cand.1 < b.1)) {
644                best = Some(cand);
645            }
646        }
647    }
648    let (prio, ch, idx) = best?;
649    let cq = st.chans.get_mut(&ch)?;
650    let item = cq.q.remove(idx)?;
651    debug_assert_eq!(item.prio, prio);
652    let is_data = matches!(item.frame.frame_type, FrameType::Data);
653    Some((ch, is_data, item.frame))
654}
655
656pub struct NodeSession {
657    channel_id: u32,
658    keys: UpstreamKeys,
659    default_prio: SendPrio,
660    down: mpsc::UnboundedReceiver<Vec<u8>>,
661    transport: NoiseTransport,
662    journal: Option<Arc<UplinkJournal>>,
663    uplink: Arc<NodeUplink>,
664    udp: bool,
665}
666
667impl NodeSession {
668    /// Whether this uplink has a connected UDP socket and the data plane is enabled.
669    pub fn udp_available(&self) -> bool {
670        self.uplink.udp_peer.is_some() && udp_pty_enabled()
671    }
672
673    /// Route this session's `SendPrio::Pty` DATA over UDP. Set once, right after open,
674    /// before any transport frame: switching later would reorder Noise nonces.
675    pub fn set_udp(&mut self, on: bool) {
676        self.udp = on && self.udp_available();
677    }
678
679    pub fn udp(&self) -> bool {
680        self.udp
681    }
682
683    pub async fn connect(
684        uplink: SocketAddr,
685        node_id: &str,
686        channel_id: u32,
687        kind: ChannelKind,
688        keys: UpstreamKeys,
689        journal: Option<Arc<UplinkJournal>>,
690        handshake_timeout: Duration,
691    ) -> Result<Self, String> {
692        let link = NodeUplink::connect(uplink, journal).await?;
693        link.open(node_id, channel_id, kind, keys, handshake_timeout).await
694    }
695
696    pub fn uplink(&self) -> Arc<NodeUplink> {
697        Arc::clone(&self.uplink)
698    }
699
700    pub async fn send(&mut self, plaintext: &[u8]) -> Result<(), String> {
701        self.send_prio(plaintext, self.default_prio).await
702    }
703
704    pub async fn send_prio(&mut self, plaintext: &[u8], prio: SendPrio) -> Result<(), String> {
705        if !self.uplink.is_alive() {
706            return Err("uplink closed".into());
707        }
708        // Refuse *before* encrypt when the channel queue cannot take this frame.
709        // Encrypt-then-drop desyncs Noise nonces (HQ white canvas / bridge decrypt errors).
710        if !self.uplink.enqueue_ok(self.channel_id, prio) {
711            if prio == SendPrio::Browser {
712                return Ok(()); // droppable
713            }
714            return Err("uplink send queue full".into());
715        }
716        let ct = self.transport.encrypt(plaintext).map_err(|e| e.to_string())?;
717        let inner = encode_inner(InnerKind::Transport, &ct);
718        self.uplink.send_inner(self.channel_id, self.keys, prio, &inner, self.udp)
719    }
720
721    pub async fn recv(&mut self, timeout: Duration) -> Result<Vec<u8>, String> {
722        tokio::time::timeout(timeout, async {
723            loop {
724                tokio::select! {
725                    bytes = self.down.recv() => {
726                        let Some(bytes) = bytes else { return Err("uplink closed".to_owned()) };
727                        if let Some((InnerKind::Transport, ct)) = decode_inner(&bytes) {
728                            return self.transport.decrypt(ct).map_err(|e| e.to_string());
729                        }
730                    }
731                    _ = self.uplink.closed() => return Err("uplink closed".to_owned()),
732                }
733            }
734        })
735        .await
736        .map_err(|_| "reply timeout".to_owned())?
737    }
738
739    pub async fn call(&mut self, op: &str, payload: &Value, timeout: Duration) -> Result<Value, String> {
740        let req = serde_json::to_vec(&json!({ "op": op, "payload": payload })).map_err(|e| e.to_string())?;
741        self.send_prio(&req, SendPrio::Mail).await?;
742        let reply = self.recv(timeout).await?;
743        let reply: Value = serde_json::from_slice(&reply).map_err(|e| e.to_string())?;
744        let status = reply.get("status").and_then(Value::as_u64).unwrap_or(500);
745        let body = reply.get("body").cloned().unwrap_or(Value::Null);
746        if status == 200 {
747            Ok(body)
748        } else {
749            let msg = body.get("error").and_then(Value::as_str).unwrap_or("error");
750            Err(format!("{status}: {msg}"))
751        }
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn pick_prefers_control_across_channels_but_keeps_data_order_within_one() {
761        let mut st = OutState { chans: BTreeMap::new() };
762        let a = st.chans.entry(1).or_insert_with(ChanQ::new);
763        a.credit = Some(10);
764        a.q.push_back(Queued { prio: SendPrio::Browser, frame: Frame::data(1, 0, vec![1]).unwrap() });
765        a.q.push_back(Queued { prio: SendPrio::Control, frame: Frame::data(1, 0, vec![2]).unwrap() });
766        let b = st.chans.entry(2).or_insert_with(ChanQ::new);
767        b.credit = Some(10);
768        b.q.push_back(Queued { prio: SendPrio::Pty, frame: Frame::data(2, 0, vec![3]).unwrap() });
769        // Channel 2's Pty beats channel 1's head (Browser); channel 1 stays in order.
770        let order: Vec<u8> = std::iter::from_fn(|| pick_next(&mut st).map(|(_, _, f)| f.payload[0])).collect();
771        assert_eq!(order, vec![3, 1, 2]);
772    }
773
774    #[test]
775    fn pick_holds_bulk_until_window() {
776        let mut st = OutState { chans: BTreeMap::new() };
777        let q = st.chans.entry(2).or_insert_with(ChanQ::new);
778        q.credit = None;
779        q.q.push_back(Queued {
780            prio: SendPrio::Control,
781            frame: Frame::data(2, 0, vec![1]).unwrap(),
782        });
783        q.q.push_back(Queued {
784            prio: SendPrio::Mail,
785            frame: Frame::data(2, 0, vec![9]).unwrap(),
786        });
787        let (_, _, f) = pick_next(&mut st).unwrap();
788        assert_eq!(f.payload, vec![1]);
789        assert!(pick_next(&mut st).is_none());
790        st.chans.get_mut(&2).unwrap().credit = Some(4);
791        let (_, _, f) = pick_next(&mut st).unwrap();
792        assert_eq!(f.payload, vec![9]);
793    }
794}