Skip to main content

hatchery_node/
uplink_calls.rs

1//! Node calls (`mail.*`, `task.*`) over the restreamer uplink.
2//!
3//! Since A2, C2 no longer forwards signed node calls to core over HTTP: it answers 410.
4//! These calls ride a dedicated `m4a` channel on the node's existing uplink. That channel
5//! is Noise-encrypted end to end to core and opaque to C2. Core takes the node id from
6//! the stream, never from the payload. The legacy HTTP path stays as a fallback for
7//! nodes without an uplink.
8//!
9//! Important: this channel shares the node's single TCP uplink with g4a. A handshake
10//! retry storm (or a wedged Noise session on core's m4a drain) can fill the TCP send
11//! buffer and stall PTY control. Open failures therefore back off hard.
12
13use crate::restream_session::{NodeSession, NodeUplink, UpstreamKeys};
14use hatchery_core::ChannelKind;
15use hatchery_enroll::NodeIdentity;
16use serde_json::Value;
17use std::net::SocketAddr;
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20use tokio::sync::Mutex;
21
22/// Channel id of the node-call channel; core runs its `m4a` drain consumer on it.
23pub const NODE_CALL_CHANNEL: u32 = 2;
24const HANDSHAKE: Duration = Duration::from_secs(8);
25const CALL_TIMEOUT: Duration = Duration::from_secs(15);
26/// After an open/handshake failure, wait before trying again (shared uplink).
27const OPEN_BACKOFF: Duration = Duration::from_secs(60);
28
29#[derive(Default)]
30struct Link {
31    uplink: Option<Arc<NodeUplink>>,
32    session: Option<NodeSession>,
33    /// Do not attempt OPEN before this instant (protects the shared uplink).
34    open_not_before: Option<Instant>,
35}
36
37/// Shared by the door and the node-mail wake; the restream bridge publishes its uplink here.
38#[derive(Clone)]
39pub struct NodeCalls {
40    link: Arc<Mutex<Link>>,
41    node_id: String,
42    keys: Option<UpstreamKeys>,
43    legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
44}
45
46#[derive(Debug)]
47pub enum CallError {
48    Refused,
49    Unavailable(String),
50}
51
52impl NodeCalls {
53    pub fn new(node_id: String, keys: Option<UpstreamKeys>, legacy: Option<(SocketAddr, Arc<NodeIdentity>)>) -> Self {
54        Self { link: Arc::default(), node_id, keys, legacy }
55    }
56
57    /// The bridge calls this on every (re)connect; `None` when the uplink is gone.
58    pub async fn set_uplink(&self, uplink: Option<Arc<NodeUplink>>) {
59        let mut l = self.link.lock().await;
60        l.uplink = uplink;
61        l.session = None;
62        // Fresh uplink: allow an immediate open attempt.
63        l.open_not_before = None;
64    }
65
66    pub async fn call(&self, op: &str, payload: &Value) -> Result<Value, CallError> {
67        if let Some(keys) = self.keys {
68            let mut l = self.link.lock().await;
69            if let Some(uplink) = l.uplink.clone() {
70                if l.session.is_none() {
71                    let gated = l.open_not_before.is_some_and(|t| Instant::now() < t);
72                    if gated {
73                        // Skip OPEN storm; fall through to legacy if any.
74                    } else {
75                        match uplink.open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE).await {
76                            Ok(s) => {
77                                tracing::info!("node calls over the uplink: channel open");
78                                l.session = Some(s);
79                                l.open_not_before = None;
80                            }
81                            Err(e) => {
82                                tracing::warn!(error = %e, "node-call channel open failed; backing off {}s", OPEN_BACKOFF.as_secs());
83                                l.open_not_before = Some(Instant::now() + OPEN_BACKOFF);
84                            }
85                        }
86                    }
87                }
88                if let Some(session) = l.session.as_mut() {
89                    return match session.call(op, payload, CALL_TIMEOUT).await {
90                        Ok(v) => Ok(v),
91                        Err(e) if e.starts_with("403") => Err(CallError::Refused),
92                        Err(e) if e.chars().take(3).all(|c| c.is_ascii_digit()) && e.contains(':') => {
93                            // Application-level error from core; keep the session.
94                            Err(CallError::Unavailable(e))
95                        }
96                        Err(e) => {
97                            // Transport fault: drop session and back off so we do not
98                            // flood OPEN/handshake1 onto a shared uplink C2 is not draining.
99                            l.session = None;
100                            l.open_not_before = Some(Instant::now() + OPEN_BACKOFF);
101                            Err(CallError::Unavailable(e))
102                        }
103                    };
104                }
105            }
106        }
107        match &self.legacy {
108            Some((c2, identity)) => match crate::enroll_client::node_call(*c2, identity, &self.node_id, op, payload).await {
109                Ok(v) => Ok(v),
110                Err(crate::enroll_client::EnrollClientError::Refused) => Err(CallError::Refused),
111                Err(e) => Err(CallError::Unavailable(format!("{e:?}"))),
112            },
113            None => Err(CallError::Unavailable("no uplink".into())),
114        }
115    }
116}