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 a **separate TCP** uplink from the
5//! g4a/PTY bridge (same Frame/Noise/OPEN wire). Core takes the node id from the
6//! stream, never from the payload. The legacy HTTP path stays as a fallback for
7//! nodes without an uplink.
8//!
9//! Splitting mail onto its own TCP means a wedged Noise session or handshake retry
10//! on ch2 cannot fill the PTY uplink's send buffer.
11
12use crate::restream_session::{NodeSession, NodeUplink, UpstreamKeys};
13use crate::uplink_journal::UplinkJournal;
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::{watch, 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 (dedicated mail TCP).
27const OPEN_BACKOFF_MIN: Duration = Duration::from_secs(5);
28const OPEN_BACKOFF_MAX: Duration = Duration::from_secs(30);
29
30#[derive(Default)]
31struct Link {
32    /// Dedicated mail TCP (not the g4a/PTY bridge uplink).
33    uplink: Option<Arc<NodeUplink>>,
34    session: Option<NodeSession>,
35    /// Do not attempt OPEN before this instant.
36    open_not_before: Option<Instant>,
37    /// Current open-failure backoff (grows 5s → 30s, resets on success).
38    open_backoff: Option<Duration>,
39}
40
41/// Shared by the door and the node-mail wake; the restream bridge signals presence here.
42#[derive(Clone)]
43pub struct NodeCalls {
44    link: Arc<Mutex<Link>>,
45    node_id: String,
46    keys: Option<UpstreamKeys>,
47    legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
48    /// Enroll HTTP address used only for C2-local config (`node.config`), even when
49    /// mail/task calls go over the restream uplink (those get 410 on the HTTP path).
50    config_http: Option<(SocketAddr, Arc<NodeIdentity>)>,
51}
52
53#[derive(Debug)]
54pub enum CallError {
55    Refused,
56    Unavailable(String),
57}
58
59impl NodeCalls {
60    pub fn new(
61        node_id: String,
62        keys: Option<UpstreamKeys>,
63        legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
64        config_http: Option<(SocketAddr, Arc<NodeIdentity>)>,
65    ) -> Self {
66        Self {
67            link: Arc::default(),
68            node_id,
69            keys,
70            legacy,
71            config_http,
72        }
73    }
74
75    /// Bridge (re)connect: refresh product URL. Mail TCP is maintained separately
76    /// by [`Self::spawn_mail_uplink`] so PTY and mail do not share fate.
77    pub async fn set_uplink(&self, uplink: Option<Arc<NodeUplink>>) {
78        let connected = uplink.is_some();
79        // Drop any stale shared-uplink session handle; mail uses its own TCP.
80        {
81            let mut l = self.link.lock().await;
82            // Do not clear `l.uplink`/`l.session` here — those belong to the mail
83            // maintainer. Presence is only for product URL.
84            let _ = &mut *l;
85        }
86        if connected {
87            self.refresh_product_url().await;
88        } else {
89            crate::m4a_accounts::set_c2_product_url(None);
90        }
91    }
92
93    /// Keep a dedicated TCP + ch2 Noise session open for node-calls.
94    /// Reconnects with exponential backoff (5s → 30s) independently of the g4a bridge.
95    pub fn spawn_mail_uplink(
96        &self,
97        addr: SocketAddr,
98        keys: UpstreamKeys,
99        journal: Option<Arc<UplinkJournal>>,
100        mut shutdown: watch::Receiver<bool>,
101    ) {
102        let this = self.clone();
103        tokio::spawn(async move {
104            let mut backoff = OPEN_BACKOFF_MIN;
105            loop {
106                if *shutdown.borrow() {
107                    break;
108                }
109                match this.dial_mail(addr, keys, journal.clone()).await {
110                    Ok(uplink) => {
111                        tracing::info!(%addr, channel = NODE_CALL_CHANNEL, "mail uplink open (dedicated TCP)");
112                        backoff = OPEN_BACKOFF_MIN;
113                        // Wait until this TCP dies or shutdown.
114                        tokio::select! {
115                            _ = uplink.closed() => {
116                                tracing::warn!(%addr, "mail uplink closed; reconnecting");
117                            }
118                            _ = shutdown.changed() => {
119                                if *shutdown.borrow() {
120                                    break;
121                                }
122                            }
123                        }
124                        let mut l = this.link.lock().await;
125                        if l.uplink.as_ref().is_some_and(|u| Arc::ptr_eq(u, &uplink)) {
126                            l.uplink = None;
127                            l.session = None;
128                        }
129                    }
130                    Err(e) => {
131                        tracing::error!(
132                            error = %e,
133                            %addr,
134                            backoff_s = backoff.as_secs(),
135                            "mail uplink open failed; backing off"
136                        );
137                        tokio::select! {
138                            _ = tokio::time::sleep(backoff) => {}
139                            _ = shutdown.changed() => {
140                                if *shutdown.borrow() { break; }
141                            }
142                        }
143                        backoff = (backoff.saturating_mul(2)).max(OPEN_BACKOFF_MIN).min(OPEN_BACKOFF_MAX);
144                    }
145                }
146            }
147            let mut l = this.link.lock().await;
148            l.uplink = None;
149            l.session = None;
150        });
151    }
152
153    async fn dial_mail(
154        &self,
155        addr: SocketAddr,
156        keys: UpstreamKeys,
157        journal: Option<Arc<UplinkJournal>>,
158    ) -> Result<Arc<NodeUplink>, String> {
159        let uplink = NodeUplink::connect(addr, journal).await?;
160        let session = uplink
161            .open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE)
162            .await?;
163        let mut l = self.link.lock().await;
164        l.uplink = Some(Arc::clone(&uplink));
165        l.session = Some(session);
166        l.open_not_before = None;
167        l.open_backoff = None;
168        Ok(uplink)
169    }
170
171    fn next_backoff(current: Option<Duration>) -> Duration {
172        match current {
173            None => OPEN_BACKOFF_MIN,
174            Some(d) => (d.saturating_mul(2)).max(OPEN_BACKOFF_MIN).min(OPEN_BACKOFF_MAX),
175        }
176    }
177
178    /// Ask C2 for its public config over enroll HTTP and stash `m4a_product_url`.
179    pub async fn refresh_product_url(&self) -> bool {
180        let Some((c2, identity)) = self.config_http.clone().or_else(|| self.legacy.clone()) else {
181            return false;
182        };
183        match crate::enroll_client::node_call(
184            c2,
185            &identity,
186            &self.node_id,
187            hatchery_enroll::call::NODE_CONFIG_OP,
188            &serde_json::json!({}),
189        )
190        .await
191        {
192            Ok(v) => {
193                let url = v
194                    .get("m4a_product_url")
195                    .and_then(|u| u.as_str())
196                    .filter(|u| !u.is_empty())
197                    .map(str::to_owned);
198                crate::m4a_accounts::set_c2_product_url(url.clone());
199                match url {
200                    Some(u) => tracing::info!(product = %u, "m4a product url from C2"),
201                    None => tracing::info!("m4a product url from C2 is empty; accounts stay local"),
202                }
203                true
204            }
205            Err(e) => {
206                tracing::warn!(error = ?e, "m4a product url from C2 failed");
207                false
208            }
209        }
210    }
211
212    /// [`Self::refresh_product_url`] with retries (5s doubling to 5 min) until C2 answers.
213    pub async fn refresh_product_url_retrying(&self) {
214        let mut wait = std::time::Duration::from_secs(5);
215        while !self.refresh_product_url().await {
216            if crate::m4a_accounts::M4aSessionAccounts::resolve_product_url(None).is_some() {
217                return;
218            }
219            tokio::time::sleep(wait).await;
220            wait = (wait * 2).min(std::time::Duration::from_secs(300));
221        }
222    }
223
224    pub async fn call(&self, op: &str, payload: &Value) -> Result<Value, CallError> {
225        if self.keys.is_some() {
226            let mut l = self.link.lock().await;
227            if l.uplink.is_some() {
228                if l.session.is_none() {
229                    let gated = l.open_not_before.is_some_and(|t| Instant::now() < t);
230                    if !gated {
231                        if let (Some(uplink), Some(keys)) = (l.uplink.clone(), self.keys) {
232                            match uplink
233                                .open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE)
234                                .await
235                            {
236                                Ok(s) => {
237                                    tracing::info!("node calls over the uplink: channel open");
238                                    l.session = Some(s);
239                                    l.open_not_before = None;
240                                    l.open_backoff = None;
241                                }
242                                Err(e) => {
243                                    let wait = Self::next_backoff(l.open_backoff);
244                                    tracing::warn!(
245                                        error = %e,
246                                        "node-call channel open failed; backing off {}s",
247                                        wait.as_secs()
248                                    );
249                                    l.open_backoff = Some(wait);
250                                    l.open_not_before = Some(Instant::now() + wait);
251                                }
252                            }
253                        }
254                    }
255                }
256                if let Some(session) = l.session.as_mut() {
257                    return match session.call(op, payload, CALL_TIMEOUT).await {
258                        Ok(v) => Ok(v),
259                        Err(e) if e.starts_with("403") => Err(CallError::Refused),
260                        Err(e)
261                            if e.chars().take(3).all(|c| c.is_ascii_digit()) && e.contains(':') =>
262                        {
263                            Err(CallError::Unavailable(e))
264                        }
265                        Err(e) => {
266                            l.session = None;
267                            let wait = Self::next_backoff(l.open_backoff);
268                            l.open_backoff = Some(wait);
269                            l.open_not_before = Some(Instant::now() + wait);
270                            Err(CallError::Unavailable(e))
271                        }
272                    };
273                }
274            }
275        }
276        match &self.legacy {
277            Some((c2, identity)) => {
278                match crate::enroll_client::node_call(*c2, identity, &self.node_id, op, payload).await
279                {
280                    Ok(v) => Ok(v),
281                    Err(crate::enroll_client::EnrollClientError::Refused) => Err(CallError::Refused),
282                    Err(e) => Err(CallError::Unavailable(format!("{e:?}"))),
283                }
284            }
285            None => Err(CallError::Unavailable("no uplink".into())),
286        }
287    }
288}