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/// How often an idle node-call channel is checked for core's re-handshake request.
30const REKEY_CHECK_EVERY: Duration = Duration::from_secs(2);
31
32#[derive(Default)]
33struct Link {
34    /// Dedicated mail TCP (not the g4a/PTY bridge uplink).
35    uplink: Option<Arc<NodeUplink>>,
36    session: Option<NodeSession>,
37    /// Do not attempt OPEN before this instant.
38    open_not_before: Option<Instant>,
39    /// Current open-failure backoff (grows 5s → 30s, resets on success).
40    open_backoff: Option<Duration>,
41}
42
43/// Shared by the door and the node-mail wake; the restream bridge signals presence here.
44#[derive(Clone)]
45pub struct NodeCalls {
46    link: Arc<Mutex<Link>>,
47    node_id: String,
48    keys: Option<UpstreamKeys>,
49    legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
50    /// Enroll HTTP address used only for C2-local config (`node.config`), even when
51    /// mail/task calls go over the restream uplink (those get 410 on the HTTP path).
52    config_http: Option<(SocketAddr, Arc<NodeIdentity>)>,
53}
54
55#[derive(Debug)]
56pub enum CallError {
57    Refused,
58    Unavailable(String),
59}
60
61impl NodeCalls {
62    pub fn new(
63        node_id: String,
64        keys: Option<UpstreamKeys>,
65        legacy: Option<(SocketAddr, Arc<NodeIdentity>)>,
66        config_http: Option<(SocketAddr, Arc<NodeIdentity>)>,
67    ) -> Self {
68        Self {
69            link: Arc::default(),
70            node_id,
71            keys,
72            legacy,
73            config_http,
74        }
75    }
76
77    /// Bridge (re)connect: refresh product URL. Mail TCP is maintained separately
78    /// by [`Self::spawn_mail_uplink`] so PTY and mail do not share fate.
79    pub async fn set_uplink(&self, uplink: Option<Arc<NodeUplink>>) {
80        let connected = uplink.is_some();
81        // Drop any stale shared-uplink session handle; mail uses its own TCP.
82        {
83            let mut l = self.link.lock().await;
84            // Do not clear `l.uplink`/`l.session` here — those belong to the mail
85            // maintainer. Presence is only for product URL.
86            let _ = &mut *l;
87        }
88        if connected {
89            self.refresh_product_url().await;
90        } else {
91            crate::m4a_accounts::set_c2_product_url(None);
92        }
93    }
94
95    /// Keep a dedicated TCP + ch2 Noise session open for node-calls.
96    /// Reconnects with exponential backoff (5s → 30s) independently of the g4a bridge.
97    pub fn spawn_mail_uplink(
98        &self,
99        addr: SocketAddr,
100        keys: UpstreamKeys,
101        journal: Option<Arc<UplinkJournal>>,
102        mut shutdown: watch::Receiver<bool>,
103    ) {
104        let this = self.clone();
105        tokio::spawn(async move {
106            let mut backoff = OPEN_BACKOFF_MIN;
107            loop {
108                if *shutdown.borrow() {
109                    break;
110                }
111                match this.dial_mail(addr, keys, journal.clone()).await {
112                    Ok(uplink) => {
113                        tracing::info!(%addr, channel = NODE_CALL_CHANNEL, "mail uplink open (dedicated TCP)");
114                        backoff = OPEN_BACKOFF_MIN;
115                        // Wait until this TCP dies or shutdown.
116                        tokio::select! {
117                            _ = uplink.closed() => {
118                                tracing::warn!(%addr, "mail uplink closed; reconnecting");
119                            }
120                            _ = shutdown.changed() => {
121                                if *shutdown.borrow() {
122                                    break;
123                                }
124                            }
125                        }
126                        let mut l = this.link.lock().await;
127                        if l.uplink.as_ref().is_some_and(|u| Arc::ptr_eq(u, &uplink)) {
128                            l.uplink = None;
129                            l.session = None;
130                        }
131                    }
132                    Err(e) => {
133                        tracing::error!(
134                            error = %e,
135                            %addr,
136                            backoff_s = backoff.as_secs(),
137                            "mail uplink open failed; backing off"
138                        );
139                        tokio::select! {
140                            _ = tokio::time::sleep(backoff) => {}
141                            _ = shutdown.changed() => {
142                                if *shutdown.borrow() { break; }
143                            }
144                        }
145                        backoff = (backoff.saturating_mul(2)).max(OPEN_BACKOFF_MIN).min(OPEN_BACKOFF_MAX);
146                    }
147                }
148            }
149            let mut l = this.link.lock().await;
150            l.uplink = None;
151            l.session = None;
152        });
153    }
154
155    async fn dial_mail(
156        &self,
157        addr: SocketAddr,
158        keys: UpstreamKeys,
159        journal: Option<Arc<UplinkJournal>>,
160    ) -> Result<Arc<NodeUplink>, String> {
161        let uplink = NodeUplink::connect(addr, journal).await?;
162        let session = uplink
163            .open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE)
164            .await?;
165        let mut l = self.link.lock().await;
166        l.uplink = Some(Arc::clone(&uplink));
167        l.session = Some(session);
168        l.open_not_before = None;
169        l.open_backoff = None;
170        Ok(uplink)
171    }
172
173    fn next_backoff(current: Option<Duration>) -> Duration {
174        match current {
175            None => OPEN_BACKOFF_MIN,
176            Some(d) => (d.saturating_mul(2)).max(OPEN_BACKOFF_MIN).min(OPEN_BACKOFF_MAX),
177        }
178    }
179
180    /// Ask C2 for its public config over enroll HTTP and stash `m4a_product_url`.
181    pub async fn refresh_product_url(&self) -> bool {
182        let Some((c2, identity)) = self.config_http.clone().or_else(|| self.legacy.clone()) else {
183            return false;
184        };
185        match crate::enroll_client::node_call(
186            c2,
187            &identity,
188            &self.node_id,
189            hatchery_enroll::call::NODE_CONFIG_OP,
190            &serde_json::json!({}),
191        )
192        .await
193        {
194            Ok(v) => {
195                let url = v
196                    .get("m4a_product_url")
197                    .and_then(|u| u.as_str())
198                    .filter(|u| !u.is_empty())
199                    .map(str::to_owned);
200                crate::m4a_accounts::set_c2_product_url(url.clone());
201                match url {
202                    Some(u) => tracing::info!(product = %u, "m4a product url from C2"),
203                    None => tracing::info!("m4a product url from C2 is empty; accounts stay local"),
204                }
205                true
206            }
207            Err(e) => {
208                tracing::warn!(error = ?e, "m4a product url from C2 failed");
209                false
210            }
211        }
212    }
213
214    /// [`Self::refresh_product_url`] with retries (5s doubling to 5 min) until C2 answers.
215    pub async fn refresh_product_url_retrying(&self) {
216        let mut wait = std::time::Duration::from_secs(5);
217        while !self.refresh_product_url().await {
218            if crate::m4a_accounts::M4aSessionAccounts::resolve_product_url(None).is_some() {
219                return;
220            }
221            tokio::time::sleep(wait).await;
222            wait = (wait * 2).min(std::time::Duration::from_secs(300));
223        }
224    }
225
226    fn sign(&self, op: &str, payload: &Value) -> Option<Value> {
227        let (_, identity) = self.config_http.as_ref().or(self.legacy.as_ref())?;
228        let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as u64;
229        let call = hatchery_enroll::call::SignedCall::sign(identity, &self.node_id, op, payload, ts).ok()?;
230        serde_json::to_value(call).ok()
231    }
232
233    /// The node-call channel only reads downlink while a call waits for its reply, so a
234    /// core restart (core then holds no keys and asks for a re-handshake) went unnoticed
235    /// until the next call failed. Check the idle channel every few seconds and
236    /// re-handshake as soon as core asks.
237    pub fn spawn_rekey_watch(&self, mut shutdown: watch::Receiver<bool>) {
238        let this = self.clone();
239        tokio::spawn(async move {
240            loop {
241                tokio::select! {
242                    _ = tokio::time::sleep(REKEY_CHECK_EVERY) => {}
243                    _ = shutdown.changed() => if *shutdown.borrow() { return },
244                }
245                let Ok(mut l) = this.link.try_lock() else { continue };
246                if l.session.as_mut().is_some_and(NodeSession::stale_downlink) {
247                    tracing::info!(channel = NODE_CALL_CHANNEL, "core asked for a new Noise session; re-handshaking");
248                    l.session = None;
249                    l.open_not_before = None;
250                    l.open_backoff = None;
251                    this.open_locked(&mut l).await;
252                }
253            }
254        });
255    }
256
257    async fn open_locked(&self, l: &mut Link) {
258        if let (Some(uplink), Some(keys)) = (l.uplink.clone(), self.keys) {
259            match uplink.open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE).await {
260                Ok(s) => {
261                    tracing::info!("node calls over the uplink: channel open");
262                    l.session = Some(s);
263                    l.open_not_before = None;
264                    l.open_backoff = None;
265                }
266                Err(e) => {
267                    let wait = Self::next_backoff(l.open_backoff);
268                    tracing::warn!(error = %e, "node-call channel open failed; backing off {}s", wait.as_secs());
269                    l.open_backoff = Some(wait);
270                    l.open_not_before = Some(Instant::now() + wait);
271                }
272            }
273        }
274    }
275
276    pub async fn call(&self, op: &str, payload: &Value) -> Result<Value, CallError> {
277        // A stale session (core restarted) costs one immediate re-handshake, not the call.
278        for attempt in 0..2 {
279            match self.call_once(op, payload).await {
280                Err(CallError::Unavailable(e)) if attempt == 0 && e.contains("decrypt") => {
281                    tracing::info!(op, "node call hit a stale Noise session; re-handshaking and retrying");
282                    let mut l = self.link.lock().await;
283                    l.open_not_before = None;
284                    l.open_backoff = None;
285                }
286                other => return other,
287            }
288        }
289        unreachable!("the second attempt returns")
290    }
291
292    async fn call_once(&self, op: &str, payload: &Value) -> Result<Value, CallError> {
293        if self.keys.is_some() {
294            let mut l = self.link.lock().await;
295            if l.session.as_mut().is_some_and(NodeSession::stale_downlink) {
296                tracing::info!(channel = NODE_CALL_CHANNEL, "core asked for a new Noise session; re-handshaking");
297                l.session = None;
298                l.open_not_before = None;
299                l.open_backoff = None;
300            }
301            if l.uplink.is_some() {
302                if l.session.is_none() {
303                    let gated = l.open_not_before.is_some_and(|t| Instant::now() < t);
304                    if !gated {
305                        if let (Some(uplink), Some(keys)) = (l.uplink.clone(), self.keys) {
306                            match uplink
307                                .open(&self.node_id, NODE_CALL_CHANNEL, ChannelKind::M4a, keys, HANDSHAKE)
308                                .await
309                            {
310                                Ok(s) => {
311                                    tracing::info!("node calls over the uplink: channel open");
312                                    l.session = Some(s);
313                                    l.open_not_before = None;
314                                    l.open_backoff = None;
315                                }
316                                Err(e) => {
317                                    let wait = Self::next_backoff(l.open_backoff);
318                                    tracing::warn!(
319                                        error = %e,
320                                        "node-call channel open failed; backing off {}s",
321                                        wait.as_secs()
322                                    );
323                                    l.open_backoff = Some(wait);
324                                    l.open_not_before = Some(Instant::now() + wait);
325                                }
326                            }
327                        }
328                    }
329                }
330                if let Some(session) = l.session.as_mut() {
331                    // The node signs every call with its enroll identity; core verifies it.
332                    let signed = self.sign(op, payload);
333                    return match session.call_signed(op, payload, signed.as_ref(), CALL_TIMEOUT).await {
334                        Ok(v) => Ok(v),
335                        Err(e) if e.starts_with("403") => Err(CallError::Refused),
336                        Err(e)
337                            if e.chars().take(3).all(|c| c.is_ascii_digit()) && e.contains(':') =>
338                        {
339                            Err(CallError::Unavailable(e))
340                        }
341                        Err(e) => {
342                            l.session = None;
343                            let wait = Self::next_backoff(l.open_backoff);
344                            l.open_backoff = Some(wait);
345                            l.open_not_before = Some(Instant::now() + wait);
346                            Err(CallError::Unavailable(e))
347                        }
348                    };
349                }
350            }
351        }
352        match &self.legacy {
353            Some((c2, identity)) => {
354                match crate::enroll_client::node_call(*c2, identity, &self.node_id, op, payload).await
355                {
356                    Ok(v) => Ok(v),
357                    Err(crate::enroll_client::EnrollClientError::Refused) => Err(CallError::Refused),
358                    Err(e) => Err(CallError::Unavailable(format!("{e:?}"))),
359                }
360            }
361            None => Err(CallError::Unavailable("no uplink".into())),
362        }
363    }
364}